Repository: webdjoe/pyvesync (language: python)

Issue:
Fix float consistency in validation function
Fix inconsistent behavior when sending out of range paramenters



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..1bb98aa
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,32 @@
+# .coveragerc to control coverage.py
+[run]
+branch = True
+omit =
+    *tests*
+
+[report]
+# Regexes for lines to exclude from consideration
+exclude_lines =
+    # Have to re-enable the standard pragma
+    pragma: no cover
+
+    # Don't complain about missing debug-only code:
+    def __repr__
+    def __str__
+    def __eq__
+    def __hash__
+    if self\.debug
+    # Disable test coverage for firmware updates
+    def firmware_update
+    def get_config
+    # Don't complain if tests don't hit defensive assertion code:
+    raise AssertionError
+    raise NotImplementedError
+
+    # Don't complain about abstract methods, they aren't run:
+    @(abc\.)?abstractmethod
+
+ignore_errors = True
+
+[html]
+directory = coverage_html_report
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 130ad60..c15cd31 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,7 +25,7 @@ src/tests/test1.py
 tools/*
 tools/
 test.py
-
+*.und
 tools/__init__.py
 tools/vesyncdevice.py
 pyvesync.und
diff --git a/.pylintrc b/.pylintrc
index 06f5e30..729e2ab 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -24,7 +24,6 @@ good-names=i,j,k,x,r,e,v,_,b,dt,d
 
 disable=
     format,
-    abstract-class-little-used,
     abstract-method,
     arguments-differ,
     cyclic-import,
@@ -33,9 +32,7 @@ disable=
     inconsistent-return-statements,
     invalid-name,
     locally-disabled,
-    no-self-use,
     not-an-iterable,
-    redefined-variable-type,
     too-few-public-methods,
     too-many-arguments,
     too-many-branches,
@@ -57,4 +54,5 @@ disable=
 [TYPECHECK]
 
 [FORMAT]
-expected-line-ending-format=LF
\ No newline at end of file
+expected-line-ending-format=LF
+max-line-length=90
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8320981..be147bc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -39,3 +39,6 @@ cd pyvesync
 tox
 ```
 
+# Ensure new devices are Integrated in Tests
+
+If you integrate a new device, please read the [testing README](tests/README.md) to ensure that your device is tested.
\ No newline at end of file
diff --git a/README.md b/README.md
index ac580c8..bc23061 100644
--- a/README.md
+++ b/README.md
@@ -23,23 +23,27 @@ pyvesync is a library to manage VeSync compatible [smart home devices](#supporte
   - [Standard Device API](#standard-device-api)
     - [Standard Properties](#standard-properties)
     - [Standard Methods](#standard-methods)
-  - [Outlet API Methods & Properties](#outlet-api-methods--properties)
-    - [Outlet power and energy API Methods & Properties](#outlet-power-and-energy-api-methods--properties)
+  - [Outlet API Methods \& Properties](#outlet-api-methods--properties)
+    - [Outlet power and energy API Methods \& Properties](#outlet-power-and-energy-api-methods--properties)
     - [Model ESW15-USA 15A/1800W Methods (Have a night light)](#model-esw15-usa-15a1800w-methods-have-a-night-light)
-  - [Standard Air Purifier Properties & Methods](#standard-air-purifier-properties--methods)
+  - [Standard Air Purifier Properties \& Methods](#standard-air-purifier-properties--methods)
     - [Air Purifier Properties](#air-purifier-properties)
     - [Air Purifier Methods](#air-purifier-methods)
     - [Levoit Purifier Core200S/300S/400S Properties](#levoit-purifier-core200s300s400s-properties)
     - [Levoit Purifier Core200S/300S/400S Methods](#levoit-purifier-core200s300s400s-methods)
-  - [Lights API Methods & Properties](#lights-api-methods-properties)
-    - [Dimmable Light Bulb Method and Properties](#dimmable-light-bulb-method-and-properties)
-    - [Tunable Light Bulb Methods and Properties](#tunable-light-bulb-methods-and-properties)
-    - [Multicolor light bulb Methods and Properties](#multicolor-light-bulb-methods-and-properties)
+  - [Lights API Methods \& Properties](#lights-api-methods--properties)
+    - [Brightness Light Bulb Method and Properties](#brightness-light-bulb-method-and-properties)
+    - [Light Bulb Color Temperature Methods and Properties](#light-bulb-color-temperature-methods-and-properties)
+    - [Multicolor Light Bulb Methods and Properties](#multicolor-light-bulb-methods-and-properties)
     - [Dimmable Switch Methods and Properties](#dimmable-switch-methods-and-properties)
   - [Levoit Humidifier Methods and Properties](#levoit-humidifier-methods-and-properties)
     - [Humidifier Properties](#humidifier-properties)
     - [Humidifer Methods](#humidifer-methods)
     - [600S warm mist feature](#600s-warm-mist-feature)
+  - [Cosori Devices](#cosori-devices)
+    - [Cosori 3.7 and 5.8 Quart Air Fryer](#cosori-37-and-58-quart-air-fryer)
+      - [Air Fryer Properties](#air-fryer-properties)
+      - [Air Fryer Methods](#air-fryer-methods)
   - [JSON Output API](#json-output-api)
     - [JSON Output for All Devices](#json-output-for-all-devices)
     - [JSON Output for Outlets](#json-output-for-outlets)
@@ -56,8 +60,6 @@ pyvesync is a library to manage VeSync compatible [smart home devices](#supporte
 - [Debug mode](#debug-mode)
 - [Feature Requests](#feature-requests)
 
-- [Contributing](CONTRIBUTING.md)
-
 
 ## Installation
 
@@ -102,7 +104,10 @@ pip install pyvesync
 2. Classic 300S
 3. LUH-D301S-WEU Dual (200S)
 4. LV600S
-5. OasisMist LUS-04515-WUS
+5. OasisMist LUS-O415S-WUS
+
+Cosori Air Fryer
+1. Cosori 3.7 and 5.8 Quart Air Fryer
 
 ## Usage
 
@@ -260,7 +265,7 @@ The rectangular smart switch model supports some additional functionality on top
 
 ### Standard Air Purifier Properties & Methods
 
-#### Air Purifier Properties 
+#### Air Purifier Properties
 
 `VeSyncFan.details` - Dictionary of device details
 
@@ -277,7 +282,7 @@ VeSyncFan.details = {
 
 ```
 
-NOTE: LV-PUR131S outputs `air_quality` as a string, such as `Excellent` 
+NOTE: LV-PUR131S outputs `air_quality` as a string, such as `Excellent`
 
 `VeSyncFan.features` - Unique features to air purifier model. Currently, the only feature is air_quality, which is not supported on Core 200S.
 
@@ -329,18 +334,16 @@ Compatible levels for each model:
 
 ### Lights API Methods & Properties
 
-#### Dimmable Light Bulb Method and Properties
+#### Brightness Light Bulb Method and Properties
 
+*Compatible with all bulbs*
 `VeSyncBulb.brightness` - Return brightness in percentage (1 - 100)
 
 `VeSyncBulb.set_brightness(brightness)` - Set bulb brightness values from 1 - 100
 
-#### Tunable Light Bulb Methods and Properties
-
-`VeSyncBulb.brightness` - Return brightness in percentage (1 - 100)
-
-`VeSyncBulb.set_brightness(brightness)` - Set bulb brightness values from 1 - 100
+#### Light Bulb Color Temperature Methods and Properties
 
+**NOTE: only compatible with ESL100CW and Valceno Bulbs, NOT compatible with ESL100MC Bulbs**
 `VeSyncBulb.color_temp_pct` - Return color temperature in percentage (0 - 100)
 
 `VeSyncBulb.color_temp_kelvin` - Return brightness in Kelvin
@@ -349,8 +352,8 @@ Compatible levels for each model:
 
 #### Multicolor Light Bulb Methods and Properties
 
+*Compatible with ESL100MC & Valceno Bulbs*
 **Properties**
-
 `VeSyncBulb.color` - Returns a dataclass with HSV and RGB attributes that are named tuples
 
 ```
@@ -362,13 +365,7 @@ VeSyncBulb.color.hsv = namedtuple('HSV', ['hue', 'saturation', 'value'])
 
 `VeSyncBulb.color_rgb` - Returns a named tuple with RGB values
 
-`VeSyncBulb.brightness` - Return brightness in percentage (int values from 1 - 100)
-
-`VeSyncBulb.color_temp_pct` - Return white temperature in percentage (int values from 0 - 100)
-
-`VeSyncBulb.color_temp_kelvin` - Return white temperature in Kelvin (int values from 2700-6500)
-
-`VeSyncBulb.color_mode` - Return bulb color mode (string values: 'white' , 'hsv' )
+`VeSyncBulb.color_mode` - Return bulb color mode (string values: 'white', 'color', 'hsv')
 
 `VeSyncBulb.color_hue` - Return color hue (float values from 0.0 - 360.0)
 
@@ -377,52 +374,39 @@ VeSyncBulb.color.hsv = namedtuple('HSV', ['hue', 'saturation', 'value'])
 `VeSyncBulb.color_value` - Return color value (int values from 0 - 100)
 
 *The following properties are also still available for backwards compatibility*
+`VeSyncBulb.color_value_hsv` - Return color value in HSV named tuple format (hue: float 0.0-360.0, saturation: float 0.0-100.0, value: float 0-100 )
 
-`VeSyncBulb.color_value_hsv` - Return color value in HSV format (float 0.0-360.0, float 0.0-100.0, int 0-100 )
-
-`VeSyncBulb.color_value_rgb` - Return color value in RGB format (float values up to 255.0, 255.0, 255.0 )
-
+`VeSyncBulb.color_value_rgb` - Return color value in RGB named tuple format (red: float, green: float, blue: float 0-255.0)
 
 **Methods**
-
-`VeSyncBulb.set_brightness(brightness)` 
-- Set bulb brightness (int values from 0 - 100) 
-- (also used to set Color Value when in color mode)
-
 `VeSyncBulb.set_hsv(hue, saturation, value)`
-- Set bulb color in HSV format 
+
+- Set bulb color in HSV format
 - Arguments: hue (numeric) 0 - 360, saturation (numeric) 0-100, value (numeric) 0-100
 - Returns bool
 
 `VeSyncBulb.set_rgb(red, green, blue)`
+
 - Set bulb color in RGB format
 - Arguments: red (numeric) 0-255, green (numeric) 0-255, blue (numeric) 0-255
 - Returns bool
 
-`VeSyncBulb.set_color_mode(color_mode)` 
-- Set bulb color mode (string values: `white` , `hsv` )
-- `color` may be used as an alias to `hsv`
+`VeSyncBulb.enable_white_mode()`
 
-`VeSyncBulb.set_color_temp(color_temp)` 
-- Set bulb white temperature (int values from 0 - 100)
-- Setting this will automatically force the bulb into White mode
+- Turn bulb to white mode - returns bool
 
-`VeSyncBulb.set_color_hue(color_hue)` 
-- Set color hue (float values from 0.0 - 360.0)
-- Setting this will automatically force the bulb into Colored mode
+`VeSyncBulb.set_color_temp(color_temp)`
 
-`VeSyncBulb.set_color_saturation(color_saturation)` 
-- Set color saturation (float values from 0.0 - 100.0)
-- Setting this will automatically force the bulb into Colored mode
+- Set bulb white temperature (int values from 0 - 100)
+- Setting this will automatically force the bulb into White mode
 
-`VeSyncBulb.set_color_value(color_value)` 
-- Set color value (float values from 0.0 - 100.0)
-- Setting this will automatically force the bulb into Colored mode
+`VeSyncBulb.set_status(brightness, color_temp, color_saturation, color_hue, color_mode color_value)`
 
-`VeSyncBulb.set_status(brightness, color_temp, color_saturation, color_hue, color_mode color_value)` 
 - Set every property, in a single call
 - All parameters are optional
 
+**NOTE: Due to the varying API between bulbs, avoid setting the `color_mode` argument directly, instead set colors values with `set_hsv` or `set_rgb` to turn on color and use `enable_white_mode()` to turn off color.**
+
 #### Dimmable Switch Methods and Properties
 
 `VeSyncSwitch.brightness` - Return brightness of switch in percentage (1 - 100)
@@ -449,7 +433,7 @@ VeSyncBulb.color.hsv = namedtuple('HSV', ['hue', 'saturation', 'value'])
 
 #### Humidifier Properties
 
-The details dictionary contains all device status details 
+The details dictionary contains all device status details
 
 ```python
 VeSyncHumid.details = {
@@ -489,7 +473,7 @@ VeSyncLV600S.details = {
 
 `VeSyncHumid.mist_level` - current mist level
 
-`VeSyncHumid.mode` - Mode of operation - sleep, off, auto/humidity 
+`VeSyncHumid.mode` - Mode of operation - sleep, off, auto/humidity
 
 `VeSyncHumid.water_lacks` - Returns True if water is low
 
@@ -501,7 +485,7 @@ VeSyncLV600S.details = {
 
 `VeSyncHumid.automatic_stop_on()` Set humidifier to stop at set humidity
 
-`VeSyncHumid.automatic_stop_off` Set humidifier to run continuously 
+`VeSyncHumid.automatic_stop_off` Set humidifier to run continuously
 
 `VeSyncHumid.turn_on_display()` Turn display on
 
@@ -521,6 +505,81 @@ VeSyncLV600S.details = {
 
 `VeSync600S.set_warm_level(2)` - Sets warm mist level
 
+### Cosori Devices
+
+Cosori devices are found under the `manager.kitchen` VeSync class attribute.
+
+#### Cosori 3.7 and 5.8 Quart Air Fryer
+
+The Cosori 3.7 and 5.8 Quart Air Fryer has several methods and properties that can be used to monitor and control
+the device.
+
+This library splits the functionality and status into two classes that are both accessible from the device instance.
+
+To maintain consistency of state, the update() method is called after each of the methods that change the state of the device.
+
+There is also an instance attribute that can be set `VeSyncAirFryer158.refresh_interval` that will set the interval in seconds that the state of the air fryer should be updated before a method that changes state is called. This is an additional API call but is necessary to maintain state, especially when trying to `pause` or `resume` the device. Defaults to 60 seconds but can be set via:
+
+```python
+# Change to 120 seconds before status is updated between calls
+VeSyncAirFryer158.refresh_interval = 120
+
+# Set status update before every call
+VeSyncAirFryer158.refresh_interval = 0
+
+# Disable status update before every call
+VeSyncAirFryer158.refresh_interval = -1
+```
+
+##### Air Fryer Properties
+
+All properties cannot be directly set, they must be set from the `get_details()` or methods that set the status.
+They can be set through the `VeSyncAirFryer158.fryer_status` dataclass but should be avoided. This separation of functionality and status is purposeful to avoid inconsistent states.
+
+`VeSyncAirFryer158.temp_unit` - Temperature units of the device (`fahrenheit` or `celsius`)
+
+`VeSyncAirFryer158.current_temp` - Current temperature in the defined temperature units. If device is not running, this defaults to `None`
+
+`VeSyncAirFryer158.cook_set_temp` - Set temperature or target temperature for preheat
+
+`VeSyncAirFryer158.cook_last_time` - The last minutes remaining returned from API for cook mode
+
+`VeSyncAirFryer158.preheat_last_time` - The last minutes remaining returned from API for preheat mode
+
+`VeSyncAirFryer158.cook_status` - Status of air fryer. This can be the following states:
+1. `standby` - Air fryer is off and no cook or preheat is in progress
+2. `cooking` - Air fryer is actively cooking
+3. `cookStop` - Cooking is paused and can be resumed
+4. `cookEnd` - Cooking is ended and can be resumed
+5. `heating` - Air fryer is preheating
+6. `preheatStop` - Preheat is paused and can be resumed
+7. `heatEnd` - Preheat is ended and cooking mode can be started with `cook_from_preheat()` method
+
+`VeSyncAirFryer158.is_heating` - Returns true if air fryer is preheating
+
+`VeSyncAirFryer158.is_cooking` - Returns true if air fryer is cooking
+
+`VeSyncAirFryer158.is_paused` - Returns true if air fryer is paused and can be resumed
+
+`VeSyncAirFryer158.remaining_time` - Returns minutes remaining based on timestamp of last API return when air fryer is running. Returns `None` if not running
+
+`VeSyncAirFryer158.fryer_status` - Dataclass that contains the status of the air fryer. The attributes of this dataclass are directly accessible from the `VeSyncAirFryer158` properties and **should not be directly set.**
+
+##### Air Fryer Methods
+
+`VeSyncAirFryer158.update()` - Retrieve current status
+
+`VeSyncAirFryer158.cook(set_temp: int, set_time: int)` - Set air fryer cook mode based on time and temp in defined units
+
+`VeSyncAirFryer158.set_preheat(target_temp: int, cook_time: int)` - Set air fryer preheat mode based on time and temp in defined units
+
+`VeSyncAirFryer158.cook_from_preheat()` - Start cook mode when air fryer is in `preheatEnd` state
+
+`VeSyncAirFryer158.pause()` - Pause air fryer when in `cooking` or `heating` state
+
+`VeSyncAirFryer158.resume()` - Resume air fryer when in `cookStop` or `preheatStop` state
+
+`VeSyncAirFryer158.end()` - End cooking or preheating and return air fryer to `standby` state
 
 ### JSON Output API
 
@@ -742,7 +801,7 @@ SSL pinning makes capturing packets with android not feasible anymore. Charles P
 
 When capturing packets make sure all packets are captured from the device list, along with all functions that the app contains. The captured packets are stored in text files, please do not capture with pcap format.
 
-After you capture the packets, please redact the `accountid` and `token`. If you feel you must redact other keys, please do not delete them entirely. Replace letters with "A" and numbers with "1", leave all punctuation intact and maintain length. 
+After you capture the packets, please redact the `accountid` and `token`. If you feel you must redact other keys, please do not delete them entirely. Replace letters with "A" and numbers with "1", leave all punctuation intact and maintain length.
 
 For example:
 
@@ -761,8 +820,10 @@ After:
   'accountId': '111111111',
   'cid': 'AAAAAA11-1AA-AA'
 }
-``` 
+```
 
 All [contributions](CONTRIBUTING.md) are welcome, please run `tox` before submitting a PR to ensure code is valid.
 
+Ensure new devices are integrated in tests, please review the [testing](tests/README.md) documentation for more information.
+
 This project is licensed under [MIT](LICENSE).
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 3cf6609..0ddac4c 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -42,12 +42,13 @@ jobs:
     vmImage: 'ubuntu-20.04'
   strategy:
     matrix:
-      Python37:
-        python.version: '3.7'
       Python38:
         python.version: '3.8'
       Python39:
         python.version: '3.9'
+      Python310:
+        python.version: '3.10'
+
 
   steps:
   - task: UsePythonVersion@0
@@ -59,7 +60,7 @@ jobs:
       set -e
       python -m pip install --upgrade pip
       pip install -r requirements.txt
-      pip install pytest pytest-azurepipelines pytest-sugar
+      pip install pytest pytest-azurepipelines pytest-sugar pyyaml
     displayName: 'Install dependencies'
   - script: |
       pytest --junitxml=reports/pytest.xml
diff --git a/setup.cfg b/setup.cfg
index b98a7ec..4978a4a 100755
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,4 +6,15 @@ license_file = LICENSE
 
 [tool:pytest]
 testpaths = src/tests
-norecursedirs = .git
\ No newline at end of file
+norecursedirs = .git
+pythonpath = src
+log_level = DEBUG
+log_cli_level = DEBUG
+log_cli = True
+
+[flake8]
+max-line-length = 90
+
+[pycodestyle]
+max-line-length = 90
+statistics = True
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 1b632a5..a4cb3a2 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@
 
 setup(
     name='pyvesync',
-    version='2.1.1',
+    version='2.1.6',
     description='pyvesync is a library to manage Etekcity\
                  Devices and Levoit Air Purifier',
     long_description=long_description,
@@ -24,12 +24,12 @@
         'Intended Audience :: Developers',
         'Intended Audience :: End Users/Desktop',
         'Natural Language :: English',
-        'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.8',
     ],
-    keywords=['iot', 'vesync', 'levoit', 'etekcity'],
+    keywords=['iot', 'vesync', 'levoit', 'etekcity', 'cosori', 'valceno'],
     packages=find_packages('src', exclude=['tests', 'tests.*']),
     package_dir={'': 'src'},
     zip_safe=False,
     install_requires=['requests>=2.20.0'],
-    python_requires='>=3.6',
+    python_requires='>=3.8',
 )
diff --git a/src/pyvesync/__init__.py b/src/pyvesync/__init__.py
index ace4302..c91ed3c 100755
--- a/src/pyvesync/__init__.py
+++ b/src/pyvesync/__init__.py
@@ -3,10 +3,6 @@
 # pylint: skip-file
 # flake8: noqa
 from .vesync import VeSync
-from .vesyncoutlet import *
-from .vesyncswitch import *
-from .vesyncoutlet import *
-from .vesyncbulb import *
 import logging
 
 
diff --git a/src/pyvesync/helpers.py b/src/pyvesync/helpers.py
index 8ef8fdf..8c7f2e9 100644
--- a/src/pyvesync/helpers.py
+++ b/src/pyvesync/helpers.py
@@ -15,6 +15,8 @@
 API_BASE_URL = 'https://smartapi.vesync.com'
 API_RATE_LIMIT = 30
 API_TIMEOUT = 5
+USER_AGENT = ("VeSync/3.2.39 (com.etekcity.vesyncPlatform;"
+              " build:5; iOS 15.5.0) Alamofire/5.2.1")
 
 DEFAULT_TZ = 'America/New_York'
 DEFAULT_REGION = 'US'
@@ -169,9 +171,8 @@ def hash_password(string) -> str:
         return hashlib.md5(string.encode('utf-8')).hexdigest()
 
     @staticmethod
-    def call_api(
-        api: str, method: str, json_object: dict = None, headers: dict = None,
-    ) -> tuple:
+    def call_api(api: str, method: str, json_object:  Optional[dict] = None,
+                 headers: Optional[dict] = None) -> tuple:
         """Make API calls by passing endpoint, header and body."""
         response = None
         status_code = None
diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py
old mode 100755
new mode 100644
index bc299ac..d8a26f0
--- a/src/pyvesync/vesync.py
+++ b/src/pyvesync/vesync.py
@@ -15,6 +15,7 @@
 from pyvesync.vesyncoutlet import *   # noqa: F403, F401
 import pyvesync.vesyncoutlet as outlet_mods
 from pyvesync.vesyncswitch import *   # noqa: F403, F401
+import pyvesync.vesynckitchen as kitchen_mods
 import pyvesync.vesyncswitch as switch_mods
 
 logger = logging.getLogger(__name__)
@@ -47,6 +48,11 @@ def bulbs(dev_type, config, manager):
         bulb_obj = getattr(bulb_mods, bulb_cls)
         return 'bulbs', bulb_obj(config, manager)
 
+    def kitchen(dev_type, config, manager):
+        kitchen_cls = kitchen_mods.kitchen_modules[dev_type]
+        kitchen_obj = getattr(kitchen_mods, kitchen_cls)
+        return 'kitchen', kitchen_obj(config, manager)
+
     if dev_type in fan_mods.fan_modules:  # type: ignore  # noqa: F405
         type_str, dev_obj = fans(dev_type, config, manager)
     elif dev_type in outlet_mods.outlet_modules:  # type: ignore  # noqa: F405
@@ -55,6 +61,8 @@ def bulbs(dev_type, config, manager):
         type_str, dev_obj = switches(dev_type, config, manager)
     elif dev_type in bulb_mods.bulb_modules:  # type: ignore  # noqa: F405
         type_str, dev_obj = bulbs(dev_type, config, manager)
+    elif dev_type in kitchen_mods.kitchen_modules:
+        type_str, dev_obj = kitchen(dev_type, config, manager)
     else:
         logger.debug('Unknown device named %s model %s',
                      config.get('deviceName', ''),
@@ -70,13 +78,20 @@ class VeSync:  # pylint: disable=function-redefined
 
     def __init__(self, username, password, time_zone=DEFAULT_TZ, debug=False):
         """Initialize VeSync class with username, password and time zone."""
-        self._debug = debug
-        if debug:
-            self.debug = debug
+        self.debug = debug
+        if debug:  # pragma: no cover
+            logger.setLevel(logging.DEBUG)
+            bulb_mods.logger.setLevel(logging.DEBUG)
+            switch_mods.logger.setLevel(logging.DEBUG)
+            outlet_mods.logger.setLevel(logging.DEBUG)
+            fan_mods.logger.setLevel(logging.DEBUG)
+            helpermodule.logger.setLevel(logging.DEBUG)
+            kitchen_mods.logger.setLevel(logging.DEBUG)
         self.username = username
         self.password = password
         self.token = None
         self.account_id = None
+        self.country_code = None
         self.devices = None
         self.enabled = False
         self.update_interval = API_RATE_LIMIT
@@ -90,12 +105,14 @@ def __init__(self, username, password, time_zone=DEFAULT_TZ, debug=False):
         self.fans = []
         self.bulbs = []
         self.scales = []
+        self.kitchen = []
 
         self._dev_list = {
             'fans': self.fans,
             'outlets': self.outlets,
             'switches': self.switches,
-            'bulbs': self.bulbs
+            'bulbs': self.bulbs,
+            'kitchen': self.kitchen
         }
 
         if isinstance(time_zone, str) and time_zone:
@@ -291,6 +308,7 @@ def login(self) -> bool:
         if Helpers.code_check(response) and 'result' in response:
             self.token = response.get('result').get('token')
             self.account_id = response.get('result').get('accountID')
+            self.country_code = response.get('result').get('countryCode')
             self.enabled = True
             logger.debug('Login successful')
             logger.debug('token %s', self.token)
diff --git a/src/pyvesync/vesyncbasedevice.py b/src/pyvesync/vesyncbasedevice.py
index 9493318..4353618 100644
--- a/src/pyvesync/vesyncbasedevice.py
+++ b/src/pyvesync/vesyncbasedevice.py
@@ -3,6 +3,7 @@
 import logging
 import json
 from typing import Optional, Union
+from pyvesync.helpers import Helpers as helper
 logger = logging.getLogger(__name__)
 
 
@@ -30,6 +31,8 @@ def __init__(self, details: dict, manager):
             self.extension = details.get('extension', None)
             self.current_firm_version = details.get(
                     'currentFirmVersion', None)
+            self.device_region: Optional[str] = details.get('deviceRegion', None)
+            self.pid = None
             self.sub_device_no = details.get('subDeviceNo', 0)
             self.config: dict = {}
             if isinstance(details.get('extension'), dict):
@@ -87,6 +90,20 @@ def firmware_update(self) -> bool:
             logger.debug('Call device.get_config() to get firmware versions')
         return False
 
+    def get_pid(self) -> None:
+        """Get managed device configuration."""
+        body = helper.req_body(self.manager, 'devicedetail')
+        body['configModule'] = self.config_module
+        body['region'] = self.device_region
+        body['method'] = 'configInfo'
+        r, _ = helper.call_api('/cloud/v1/deviceManaged/configInfo',
+                               'post',
+                               json_object=body)
+        if not isinstance(r, dict) or r.get('code') != 0 or r.get('result') is None:
+            logger.error('Error getting config info for %s', self.device_name)
+            return
+        self.pid = r.get('result', {}).get('pid')
+
     def display(self) -> None:
         """Print formatted device info to stdout."""
         disp = [
diff --git a/src/pyvesync/vesyncbulb.py b/src/pyvesync/vesyncbulb.py
index 1dbf4f5..e6dfd15 100644
--- a/src/pyvesync/vesyncbulb.py
+++ b/src/pyvesync/vesyncbulb.py
@@ -72,7 +72,7 @@ def __init__(self, details: Dict[str, Union[str, list]],
             self.device_type, {}).get('features')
         if self.features is None:
             logger.error("No configuration set for - %s", self.device_name)
-            raise Exception
+            raise KeyError(f"No configuration set for {self.device_name}")
         self._rgb_values = {
             'red': 0,
             'green': 0,
@@ -262,11 +262,13 @@ def _validate_color_temp(self, temp: int, start: int = 0, stop: int = 100):
         return temp_update
 
     @staticmethod
-    def _validate_any(value: Union[int, float, str], start: float = 0,
-                      stop: float = 100, default: float = 100) -> float:
+    def _validate_any(value: Union[int, float, str],
+                      start: Union[int, float] = 0,
+                      stop: Union[int, float] = 100,
+                      default: Union[int, float] = 100) -> float:
         """Validate any value."""
         try:
-            value_update = max(start, (min(stop, round(float(value), 2))))
+            value_update = max(float(start), (min(float(stop), round(float(value), 2))))
         except (ValueError, TypeError):
             value_update = default
         return value_update
@@ -346,7 +348,7 @@ def display(self) -> None:
                 disp.append(('ColorHSV: ', helpers.named_tuple_to_str(
                     self.color.hsv), ''))
                 disp.append(('ColorRGB: ', helpers.named_tuple_to_str(
-                    self.color.hsv), ''))
+                    self.color.rgb), ''))
                 disp.append(('ColorMode: ', str(self.color_mode), ''))
             if len(disp) > 0:
                 for line in disp:
@@ -456,25 +458,31 @@ def set_hsv(self, hue, saturation, value):
         rgb = Color(hue=hue, saturation=saturation, value=value).rgb
         return self.set_status(red=rgb.red, green=rgb.green, blue=rgb.blue)
 
+    def enable_white_mode(self) -> bool:
+        """Enable white mode on bulb."""
+        return self.set_status(brightness=100)
+
     def set_status(self, brightness: Optional[NUMERIC_T] = None,
                    red: Optional[NUMERIC_T] = None,
                    green: Optional[NUMERIC_T] = None,
-                   blue: Optional[NUMERIC_T] = None,
-                   color_mode: Optional[NUMERIC_T] = None) -> bool:
+                   blue: Optional[NUMERIC_T] = None) -> bool:
         """Set status of VeSync ESL100MC."""
-        if red is not None or green is not None or blue is not None:
+        brightness_update = 100
+        if red is not None and green is not None and blue is not None:
             new_color = self._validate_rgb(red, green, blue)
+            color_mode = 'color'
+            if new_color == self._color:
+                logger.debug("New color is same as current color")
+                return True
         else:
+            logger.debug("RGB Values not provided")
             new_color = None
-        if brightness is not None:
-            brightness = int(self._validate_brightness(brightness))
-        else:
-            brightness = None
-
-        if brightness == self.brightness and \
-                new_color == self.color:
-            logger.debug("No change in status")
-            return True
+            if brightness is not None:
+                brightness_update = int(self._validate_brightness(brightness))
+                color_mode = 'white'
+            else:
+                logger.debug("Brightness and RGB values are not set")
+                return False
 
         head = helpers.bypass_header()
         body = helpers.bypass_body_v2(self.manager)
@@ -486,18 +494,14 @@ def set_status(self, brightness: Optional[NUMERIC_T] = None,
             'data': {
                 'action': 'on',
                 'speed': 0,
+                'brightness': brightness_update,
+                'red': 0 if new_color is None else int(new_color.rgb.red),
+                'green': 0 if new_color is None else int(new_color.rgb.green),
+                'blue': 0 if new_color is None else int(new_color.rgb.blue),
+                'colorMode': 'color' if new_color is not None else 'white',
             }
         }
 
-        if new_color is not None:
-            body['payload']['data']['red'] = int(new_color.rgb.red)
-            body['payload']['data']['green'] = int(new_color.rgb.green)
-            body['payload']['data']['blue'] = int(new_color.rgb.blue)
-            body['payload']['data']['colorMode'] = 'color'
-
-        if brightness is not None:
-            body['payload']['data']['brightness'] = brightness
-
         r, _ = helpers.call_api(
             '/cloud/v2/deviceManaged/bypassV2',
             method='post',
@@ -507,12 +511,16 @@ def set_status(self, brightness: Optional[NUMERIC_T] = None,
         if not isinstance(r, dict) or r.get('code') != 0:
             logger.debug("Error in setting bulb status")
             return False
-        if new_color is not None:
-            self._color = Color(red=int(new_color.rgb.red),
-                                green=int(new_color.rgb.green),
-                                blue=int(new_color.rgb.blue))
-        if brightness is not None:
-            self._brightness = brightness
+
+        if color_mode == 'color' and new_color is not None:
+            self._color_mode = 'color'
+            self._color = Color(red=new_color.rgb.red,
+                                green=new_color.rgb.green,
+                                blue=new_color.rgb.blue)
+        elif brightness is not None:
+            self._brightness = int(brightness_update)
+            self._color_mode = 'white'
+
         self.device_status = 'on'
         return True
 
@@ -556,7 +564,7 @@ def toggle(self, status: str) -> bool:
 class VeSyncBulbESL100(VeSyncBulb):
     """Object to hold VeSync ESL100 light bulb."""
 
-    def __init__(self, details, manager):
+    def __init__(self, details: dict, manager) -> None:
         """Initialize Etekcity ESL100 Dimmable Bulb."""
         super().__init__(details, manager)
         self.details: dict = {}
@@ -1006,12 +1014,16 @@ def set_hsv(self, hue: NUMERIC_T = None,
         arg_dict['colorMode'] = 'hsv'
         return self._set_status_api(arg_dict)
 
+    def enable_white_mode(self) -> bool:
+        """Enable white color mode."""
+        return self.set_status(color_mode='white')
+
     def set_status(self,
                    brightness: NUMERIC_T = None,
                    color_temp: NUMERIC_T = None,
                    color_saturation: NUMERIC_T = None,
                    color_hue: NUMERIC_T = None,
-                   color_mode: str = None,
+                   color_mode: Optional[str] = None,
                    color_value: NUMERIC_T = None
                    ) -> bool:
         """Set multicolor bulb parameters.
diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py
index a7670ca..5ea4ae1 100644
--- a/src/pyvesync/vesyncfan.py
+++ b/src/pyvesync/vesyncfan.py
@@ -2,7 +2,7 @@
 
 import json
 import logging
-from typing import Dict, Tuple, Union
+from typing import Dict, Tuple, Union, Optional
 from pyvesync.vesyncbasedevice import VeSyncBaseDevice
 from pyvesync.helpers import Helpers
 
@@ -65,7 +65,7 @@
     },
     'Core300S': {
         'module': 'VeSyncAirBypass',
-        'models': ['Core300S', 'LAP-C301S-WJP'],
+        'models': ['Core300S', 'LAP-C301S-WJP', 'LAP-C302S-WUSB'],
         'modes': ['sleep', 'off', 'auto', 'manual'],
         'features': ['air_quality'],
         'levels': list(range(1, 5))
@@ -139,7 +139,7 @@ def __init__(self, details: Dict[str, list], manager):
             logger.error(
                 'Please set modes for %s in the configuration',
                 self.device_type)
-            raise Exception
+            raise KeyError(f'Modes not set in configuration for {self.device_name}')
         self.modes = self.config_dict['modes']
         if 'air_quality' in self.features:
             self.air_quality_feature = True
@@ -200,7 +200,7 @@ def build_purifier_dict(self, dev_dict: dict) -> None:
         self.details['display'] = dev_dict.get('display', False)
         self.details['display_forever'] = dev_dict.get('display_forever',
                                                        False)
-        if self.air_quality_feature:
+        if self.air_quality_feature is True:
             self.details['air_quality_value'] = dev_dict.get(
                 'air_quality_value', 0)
             self.details['air_quality'] = dev_dict.get('air_quality', 0)
@@ -571,9 +571,9 @@ def display(self) -> None:
         if self.air_quality_feature:
             disp.extend([
                 ('Air Quality Level: ',
-                    self.details['air_quality'], ''),
+                    self.details.get('air_quality', ''), ''),
                 ('Air Quality Value: ',
-                    self.details['air_quality_value'], 'ug/m3')
+                    self.details.get('air_quality_value', ''), 'ug/m3')
                 ])
         for line in disp:
             print(f'{line[0]:.<30} {line[1]} {line[2]}')
@@ -594,12 +594,12 @@ def displayJSON(self) -> str:
                 'Display_Forever Config': self.config['display_forever'],
             }
         )
-        if self.air_quality_feature:
+        if self.air_quality_feature is True:
             sup_val.update(
-                {'Air Quality Level': str(self.details['air_quality'])}
+                {'Air Quality Level': str(self.details.get('air_quality', ''))}
             )
             sup_val.update(
-                {'Air Quality Value': str(self.details['air_quality_value'])}
+                {'Air Quality Value': str(self.details.get('air_quality_value', ''))}
             )
         return json.dumps(sup_val, indent=4)
 
@@ -737,7 +737,7 @@ def sleep_mode(self) -> bool:
         """Set sleep mode to on."""
         return self.mode_toggle('sleep')
 
-    def change_fan_speed(self, speed: int = None) -> bool:
+    def change_fan_speed(self, speed: Optional[int] = None) -> bool:
         """Adjust Fan Speed for air purifier.
 
         Specifying 1,2,3 as argument or call without argument to cycle
@@ -865,7 +865,7 @@ def __init__(self, details, manager):
             self.night_light = True
         else:
             self.night_light = False
-        self.details: Dict[str, Union[str, int, float]] = {
+        self.details = {
             'humidity': 0,
             'mist_virtual_level': 0,
             'mist_level': 0,
@@ -878,7 +878,7 @@ def __init__(self, details, manager):
         }
         if self.night_light is True:
             self.details['night_light_brightness'] = 0
-        self.config: Dict[str, Union[str, int, float]] = {
+        self.config = {
             'auto_target_humidity': 0,
             'display': False,
             'automatic_stop': True
@@ -1279,7 +1279,7 @@ def humidity(self):
     @property
     def mist_level(self):
         """Get current mist level."""
-        return self.details['virtual_mist_level']
+        return self.details['mist_virtual_level']
 
     @property
     def water_lacks(self):
@@ -1326,12 +1326,12 @@ def display(self) -> None:
         ]
         if self.night_light:
             disp.append(('Night Light Brightness: ',
-                         self.details['night_light_brightness'], 'percent'))
+                         self.details.get('night_light_brightness', ''), 'percent'))
         if self.warm_mist_feature:
             disp.append(('Warm mist enabled: ',
-                         self.details['warm_mist_enabled'], ''))
+                         self.details.get('warm_mist_enabled', ''), ''))
             disp.append(('Warm mist level: ',
-                         self.details['warm_mist_level'], ''))
+                         self.details.get('warm_mist_level', ''), ''))
         for line in disp:
             print(f'{line[0]:.<30} {line[1]} {line[2]}')
 
diff --git a/src/pyvesync/vesynckitchen.py b/src/pyvesync/vesynckitchen.py
new file mode 100644
index 0000000..c9b2ffd
--- /dev/null
+++ b/src/pyvesync/vesynckitchen.py
@@ -0,0 +1,655 @@
+"""VeSync Kitchen Devices."""
+import json
+import logging
+import time
+from functools import wraps
+from typing import Optional, Union, Set
+from dataclasses import dataclass
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.helpers import Helpers as helpers
+
+logger = logging.getLogger(__name__)
+
+
+kitchen_features: dict = {
+    'Cosori3758L': {
+        'module': 'VeSyncAirFryer158',
+        'models': ['CS137-AF/CS158-AF', 'CS158-AF', 'CS137-AF', 'CS358-AF'],
+        'features': [],
+    }
+}
+
+
+def model_dict() -> dict:
+    """Build purifier and humidifier model dictionary."""
+    model_modules = {}
+    for dev_dict in kitchen_features.values():
+        for model in dev_dict['models']:
+            model_modules[model] = dev_dict['module']
+    return model_modules
+
+
+def model_features(dev_type: str) -> dict:
+    """Get features from device type."""
+    for dev_dict in kitchen_features.values():
+        if dev_type in dev_dict['models']:
+            return dev_dict
+    raise ValueError('Device not configured')
+
+
+kitchen_classes: Set[str] = {v['module'] for k, v in kitchen_features.items()}
+
+
+kitchen_modules: dict = model_dict()
+
+
+__all__ = list(kitchen_classes)
+
+
+# Status refresh interval in seconds
+# API calls outside of interval are automatically refreshed
+# Set VeSyncAirFryer158.refresh_interval to 0 to refresh every call
+# Set to None or -1 to disable auto-refresh
+REFRESH_INTERVAL = 60
+
+RECIPE_ID = 1
+RECIPE_TYPE = 3
+CUSTOM_RECIPE = 'Manual Cook'
+COOK_MODE = 'custom'
+
+
+def check_status(func):
+    """Check interval between updates."""
+    @wraps(func)
+    def wrapper(self, *args, **kwargs):
+        seconds_elapsed = int(time.time()) - self.last_update
+        logger.debug("Seconds elapsed between updates: %s", seconds_elapsed)
+        refresh = False
+        if self.refresh_interval is None:
+            refresh = bool(seconds_elapsed > REFRESH_INTERVAL)
+        elif self.refresh_interval == 0:
+            refresh = True
+        elif self.refresh_interval > 0:
+            refresh = bool(seconds_elapsed > self.refresh_interval)
+        if refresh is True:
+            logger.debug("Updating status, %s seconds elapsed", seconds_elapsed)
+            self.update()
+        return func(self, *args, **kwargs)
+    return wrapper
+
+
+@dataclass(init=False, eq=False, repr=False)
+class FryerStatus:
+    """Dataclass for air fryer status."""
+
+    ready_start: bool = False
+    preheat: bool = False
+    cook_status: Optional[str] = None
+    current_temp: Optional[int] = None
+    cook_set_temp: Optional[int] = None
+    cook_set_time: Optional[int] = None
+    cook_last_time: Optional[int] = None
+    last_timestamp: Optional[int] = None
+    preheat_set_time: Optional[int] = None
+    preheat_last_time: Optional[int] = None
+    _temp_unit: Optional[str] = None
+
+    @property
+    def is_resumable(self) -> bool:
+        """Return if cook is resumable."""
+        if self.cook_status in ['cookStop', 'preheatStop']:
+            if self.cook_set_time is not None:
+                return self.cook_set_time > 0
+            if self.preheat_set_time is not None:
+                return self.preheat_set_time > 0
+        return False
+
+    @property
+    def temp_unit(self) -> Optional[str]:
+        """Return temperature unit."""
+        return self._temp_unit
+
+    @temp_unit.setter
+    def temp_unit(self, temp_unit: str):
+        """Set temperature unit."""
+        if temp_unit.lower() in ['f', 'fahrenheit']:
+            self._temp_unit = 'fahrenheit'
+        elif temp_unit.lower() in ['c', 'celsius']:
+            self._temp_unit = 'celsius'
+        else:
+            raise ValueError(f'Invalid temperature unit - {temp_unit}')
+
+    @property
+    def preheat_time_remaining(self) -> int:
+        """Return preheat time remaining."""
+        if self.preheat is False or self.cook_status == 'preheatEnd':
+            return 0
+        if self.cook_status in ['pullOut', 'preheatStop']:
+            if self.preheat_last_time is None:
+                return 0
+            return int(self.preheat_last_time // 60)
+        if self.preheat_last_time is not None and self.last_timestamp is not None:
+            return int(max((self.preheat_last_time -
+                            (int(time.time()) - self.last_timestamp)) // 60, 0))
+        return 0
+
+    @property
+    def cook_time_remaining(self) -> int:
+        """Returns the amount of time remaining if cooking."""
+        if self.preheat is True or self.cook_status == 'cookEnd':
+            return 0
+        if self.cook_status in ['pullOut', 'cookStop']:
+            if self.cook_last_time is None:
+                return 0
+            return int(max(self.cook_last_time // 60, 0))
+        if self.cook_last_time is not None and self.last_timestamp is not None:
+            return int(max((self.cook_last_time -
+                            (int(time.time()) - self.last_timestamp)) // 60, 0))
+        return 0
+
+    @property
+    def remaining_time(self):
+        """Return minutes remaining if cooking/heating."""
+        if self.preheat is True:
+            return self.preheat_time_remaining
+        return self.cook_time_remaining
+
+    @property
+    def is_running(self) -> bool:
+        """Return if cooking or heating."""
+        return bool(self.cook_status in ['cooking', 'heating']) and \
+            bool(self.remaining_time > 0)
+
+    @property
+    def is_cooking(self) -> bool:
+        """Return if cooking."""
+        return self.cook_status == 'cooking' and self.remaining_time > 0
+
+    @property
+    def is_heating(self) -> bool:
+        """Return if heating."""
+        return self.cook_status == 'heating' and self.remaining_time > 0
+
+    def status_request(self, json_cmd: dict):
+        """Set status from jsonCmd of API call."""
+        self.last_timestamp = None
+        if not isinstance(json_cmd, dict):
+            return
+        self.preheat = False
+        preheat = json_cmd.get('preheat')
+        cook = json_cmd.get('cookMode')
+        if isinstance(preheat, dict):
+            self.preheat = True
+            if preheat.get('preheatStatus') == 'stop':
+                self.cook_status = 'preheatStop'
+            elif preheat.get('preheatStatus') == 'heating':
+                self.cook_status = 'heating'
+                self.last_timestamp = int(time.time())
+                self.preheat_set_time = preheat.get('preheatSetTime',
+                                                    self.preheat_set_time)
+                if preheat.get('preheatSetTime') is not None:
+                    self.preheat_last_time = preheat.get('preheatSetTime')
+                self.cook_set_temp = preheat.get('targetTemp', self.cook_set_temp)
+                self.cook_set_time = preheat.get('cookSetTime', self.cook_set_time)
+                self.cook_last_time = None
+            elif preheat.get('preheatStatus') == 'end':
+                self.cook_status = 'preheatEnd'
+                self.preheat_last_time = 0
+        elif isinstance(cook, dict):
+            self.clear_preheat()
+            if cook.get('cookStatus') == 'stop':
+                self.cook_status = 'cookStop'
+            elif cook.get('cookStatus') == 'cooking':
+                self.cook_status = 'cooking'
+                self.last_timestamp = int(time.time())
+                self.cook_set_time = cook.get('cookSetTime', self.cook_set_time)
+                self.cook_set_temp = cook.get('cookSetTemp', self.cook_set_temp)
+                self.current_temp = cook.get('currentTemp', self.current_temp)
+                self.temp_unit = cook.get('tempUnit', self.temp_unit)
+            elif cook.get('cookStatus') == 'end':
+                self.set_standby()
+                self.cook_status = 'cookEnd'
+
+    def clear_preheat(self):
+        """Clear preheat status."""
+        self.preheat = False
+        self.preheat_set_time = None
+        self.preheat_last_time = None
+
+    def set_standby(self):
+        """Clear cooking status."""
+        self.cook_status = 'standby'
+        self.clear_preheat()
+        self.cook_last_time = None
+        self.current_temp = None
+        self.cook_set_time = None
+        self.cook_set_temp = None
+        self.last_timestamp = None
+
+    def status_response(self, return_status: dict) -> None:
+        """Set status of Air Fryer Based on API Response."""
+        self.last_timestamp = None
+        self.preheat = False
+        self.cook_status = return_status.get('cookStatus')
+        if self.cook_status == 'standby':
+            self.set_standby()
+            return
+
+        #  If drawer is pulled out, set standby if resp does not contain other details
+        if self.cook_status == 'pullOut':
+            self.last_timestamp = None
+            if 'currentTemp' not in return_status or 'tempUnit' not in return_status:
+                self.set_standby()
+                self.cook_status = 'pullOut'
+                return
+        if return_status.get('preheatLastTime') is not None or \
+                self.cook_status in ['heating', 'preheatStop', 'preheatEnd']:
+            self.preheat = True
+
+        self.cook_set_time = return_status.get('cookSetTime', self.cook_set_time)
+        self.cook_last_time = return_status.get('cookLastTime')
+        self.current_temp = return_status.get('curentTemp')
+        self.cook_set_temp = return_status.get('targetTemp',
+                                               return_status.get('cookSetTemp'))
+        self.temp_unit = return_status.get('tempUnit', self.temp_unit)  # Always keep set
+        self.preheat_set_time = return_status.get('preheatSetTime')
+        self.preheat_last_time = return_status.get('preheatLastTime')
+
+        #  Set last_time timestamp if cooking
+        if self.cook_status in ['cooking', 'heating']:
+            self.last_timestamp = int(time.time())
+
+        if self.cook_status == 'preheatEnd':
+            self.preheat_last_time = 0
+            self.cook_last_time = None
+        if self.cook_status == 'cookEnd':
+            self.cook_last_time = 0
+
+        #  If Cooking, clear preheat status
+        if self.cook_status in ['cooking', 'cookStop', 'cookEnd']:
+            self.clear_preheat()
+
+
+class VeSyncAirFryer158(VeSyncBaseDevice):
+    """Cosori Air Fryer Class."""
+
+    def __init__(self, details, manager):
+        """Init the VeSync Air Fryer 158 class."""
+        super().__init__(details, manager)
+        self.fryer_status = FryerStatus()
+
+        if self.pid is None:
+            self.get_pid()
+        self.fryer_status.temp_unit = self.get_temp_unit()
+        self.ready_start = self.get_remote_cook_mode()
+        self.last_update = int(time.time())
+        self.refresh_interval = 0
+
+    def get_body(self, method:  Optional[str] = None) -> dict:
+        """Return body of api calls."""
+        body = {
+            **helpers.req_body(self.manager, 'bypass'),
+            'cid': self.cid,
+            'userCountryCode': self.manager.country_code,
+            'debugMode': False
+        }
+        if method is not None:
+            body['method'] = method
+        return body
+
+    def get_status_body(self, cmd_dict: dict) -> dict:
+        """Return body of api calls."""
+        body = self.get_body()
+        body.update({
+            'uuid': self.uuid,
+            'configModule': self.config_module,
+            'jsonCmd': cmd_dict,
+            'pid': self.pid,
+            'accountID': self.manager.account_id
+            }
+        )
+        return body
+
+    def get_temp_unit(self):
+        """Get Air Fryer Configuration."""
+        body = self.get_body('configurationsV2')
+
+        r, _ = helpers.call_api('/cloud/v2/deviceManaged/configurationsV2', 'post',
+                                json_object=body)
+        if not isinstance(r, dict) or r.get('code') != 0 \
+                or not isinstance(r.get('result'), dict):
+            logger.debug('Failed to get config for %s', self.device_name)
+            return
+        result = r.get('result')
+        if result is not None:
+            return result.get('airFryerInfo', {}).get('workTempUnit', 'f')
+
+    def get_remote_cook_mode(self):
+        """Get the cook mode."""
+        body = self.get_body('getRemoteCookMode158')
+        r, _ = helpers.call_api('/cloud/v1/deviceManaged/getRemoteCookMode158',
+                                'post',
+                                json_object=body)
+        if not isinstance(r, dict) or r.get('code') != 0 \
+                or not isinstance(r.get('result'), dict):
+            return False
+        return r.get('result', {}).get('readyStart', False)
+
+    @property
+    def temp_unit(self) -> Optional[str]:
+        """Return temp unit."""
+        return self.fryer_status.temp_unit
+
+    @property
+    def current_temp(self) -> Optional[int]:
+        """Return current temperature."""
+        return self.fryer_status.current_temp
+
+    @property
+    def cook_set_temp(self) -> Optional[int]:
+        """Return set temperature."""
+        return self.fryer_status.cook_set_temp
+
+    @property
+    def preheat(self) -> bool:
+        """Return preheat status."""
+        return self.fryer_status.preheat
+
+    @property
+    def cook_last_time(self) -> Optional[int]:
+        """Return cook last time."""
+        return self.fryer_status.cook_last_time
+
+    @property
+    def cook_set_time(self) -> Optional[int]:
+        """Return cook set time."""
+        return self.fryer_status.cook_set_time
+
+    @property
+    def preheat_last_time(self) -> Optional[int]:
+        """Return preheat last time."""
+        if self.preheat is True:
+            return self.fryer_status.preheat_last_time
+        return None
+
+    @property
+    def preheat_set_time(self) -> Optional[int]:
+        """Return preheat set time."""
+        if self.preheat is True:
+            return self.fryer_status.preheat_set_time
+        return None
+
+    @property
+    def cook_status(self) -> Optional[str]:
+        """Return the cook status."""
+        return self.fryer_status.cook_status
+
+    @property
+    def is_cooking(self) -> bool:
+        """Return True if air fryer is heating."""
+        return self.fryer_status.is_cooking
+
+    @property
+    def is_heating(self) -> bool:
+        """Return True if air fryer is preheating."""
+        return self.fryer_status.is_heating
+
+    @property
+    def is_running(self):
+        """Return True if cooking or preheating finished."""
+        return self.fryer_status.is_running
+
+    @property
+    def remaining_time(self) -> Optional[int]:
+        """Return time remaining in minutes or None if not cooking/heating."""
+        return self.fryer_status.remaining_time
+
+    def get_details(self):
+        """Get Air Fryer Status and Details."""
+        cmd = {'getStatus': 'status'}
+        req_body = self.get_status_body(cmd)
+        url = '/cloud/v1/deviceManaged/bypass'
+        resp, _ = helpers.call_api(url, 'post', json_object=req_body)
+
+        if resp is None:
+            logger.debug('Failed to get details for %s', self.device_name)
+            return False
+        if resp.get('code') == -11300030:
+            logger.debug('%s is offline', self.device_name)
+            self.fryer_status.set_standby()
+            self.fryer_status.cook_status = 'offline'
+            return False
+        if resp.get('code') != 0:
+            logger.debug('Failed to get details for %s \n with code: %s and message: %s',
+                         self.device_name, str(resp.get("code", 0)), resp.get("msg", ''))
+            return False
+        return_status = resp.get('result', {}).get('returnStatus')
+        if return_status is None:
+            return False
+        self.fryer_status.status_response(return_status)
+        return True
+
+    @check_status
+    def end(self):
+        """End the cooking process."""
+        if self.preheat is False \
+                and self.fryer_status.cook_status in ['cookStop', 'cooking']:
+            cmd = {
+                'cookMode': {
+                    'cookStatus': 'end'
+                }
+            }
+        elif self.preheat is True \
+                and self.fryer_status.cook_status in ['preheatStop', 'heating']:
+            cmd = {
+                'preheat': {
+                    'cookStatus': 'end'
+                }
+            }
+        else:
+            logger.debug('Cannot end %s as it is not cooking or preheating',
+                         self.device_name)
+            return False
+
+        if self._status_api(cmd) is True:
+            self.fryer_status.set_standby()
+            return True
+        return False
+
+    @check_status
+    def pause(self) -> bool:
+        """Pause the cooking process."""
+        if self.cook_status not in ['cooking', 'heating']:
+            logger.debug('Cannot pause %s as it is not cooking or preheating',
+                         self.device_name)
+            return False
+        if self.preheat is True:
+            cmd = {
+                'preheat': {
+                    'preheatStatus': 'stop'
+                }
+            }
+        else:
+            cmd = {
+                'cookMode': {
+                    'cookStatus': 'stop'
+                }
+            }
+        if self._status_api(cmd) is True:
+            if self.preheat is True:
+                self.fryer_status.cook_status = 'preheatStop'
+            else:
+                self.fryer_status.cook_status = 'cookStop'
+            return True
+        return False
+
+    def _validate_temp(self, set_temp: int) -> bool:
+        """Temperature validation."""
+        if self.fryer_status.temp_unit == 'fahrenheight':
+            if set_temp < 200 or set_temp > 400:
+                logger.debug('Invalid temperature %s for %s', set_temp, self.device_name)
+                return False
+        if self.fryer_status.temp_unit == 'celsius':
+            if set_temp < 75 or set_temp > 205:
+                logger.debug('Invalid temperature %s for %s', set_temp, self.device_name)
+                return False
+        return True
+
+    @check_status
+    def cook(self, set_temp: int, set_time: int) -> bool:
+        """Set cook time and temperature in Minutes."""
+        if self._validate_temp(set_temp) is False:
+            return False
+        return self._set_cook(set_temp, set_time)
+
+    @check_status
+    def resume(self) -> bool:
+        """Resume paused preheat or cook."""
+        if self.cook_status not in ['preheatStop', 'cookStop']:
+            logger.debug('Cannot resume %s as it is not paused', self.device_name)
+            return False
+        if self.preheat is True:
+            cmd = {
+                'preheat': {
+                    'preheatStatus': 'heating'
+                }
+            }
+        else:
+            cmd = {
+                'cookMode': {
+                    'cookStatus': 'cooking'
+                }
+            }
+        if self._status_api(cmd) is True:
+            if self.preheat is True:
+                self.fryer_status.cook_status = 'heating'
+            else:
+                self.fryer_status.cook_status = 'cooking'
+            return True
+        return False
+
+    @check_status
+    def set_preheat(self, target_temp: int, cook_time: int) -> bool:
+        """Set preheat mode with cooking time."""
+        if self.cook_status not in ['standby', 'cookEnd', 'preheatEnd']:
+            logger.debug('Cannot set preheat for %s as it is not in standby',
+                         self.device_name)
+            return False
+        if self._validate_temp(target_temp) is False:
+            return False
+        cmd = self._cmd_api_dict
+        cmd['preheatSetTime'] = 5
+        cmd['preheatStatus'] = 'heating'
+        cmd['targetTemp'] = target_temp
+        cmd['cookSetTime'] = cook_time
+        json_cmd = {
+            'preheat': cmd
+        }
+        return self._status_api(json_cmd)
+
+    # @check_status
+    def cook_from_preheat(self) -> bool:
+        """Start Cook when preheat has ended."""
+        if self.preheat is False or self.cook_status != 'preheatEnd':
+            logger.debug('Cannot start cook from preheat for %s', self.device_name)
+            return False
+        return self._set_cook(status='cooking')
+
+    def update(self):
+        """Update the device details."""
+        self.get_details()
+
+    @staticmethod
+    def fryer_code_check(code: Union[str, int]) -> Optional[str]:
+        """Return the code description."""
+        if isinstance(code, str):
+            try:
+                code = int(code)
+            except ValueError:
+                return None
+        if code == 11903000:
+            return 'Error pausing, air fryer is not cooking.'
+        if code == 11902000:
+            return 'Error setting cook mode, air fryer is already cooking'
+        if str(abs(code))[0:5] == 11300:
+            return 'Air fryer is offline'
+        return None
+
+    @property
+    def _cmd_api_base(self) -> dict:
+        """Return Base api dictionary for setting status."""
+        return {
+            "mode": COOK_MODE,
+            "accountId": self.manager.account_id,
+        }
+
+    @property
+    def _cmd_api_dict(self) -> dict:
+        """Return API dictionary for setting status."""
+        cmd = self._cmd_api_base
+        cmd.update({
+            "appointmentTs": 0,
+            "recipeId": RECIPE_ID,
+            "readyStart": self.ready_start,
+            "recipeType": RECIPE_TYPE,
+            "customRecipe": CUSTOM_RECIPE,
+        })
+        return cmd
+
+    def _set_cook(self, set_temp: Optional[int] = None,
+                  set_time: Optional[int] = None, status: str = 'cooking') -> bool:
+        if set_temp is not None and set_time is not None:
+            set_cmd = self._cmd_api_dict
+
+            set_cmd['cookSetTime'] = set_time
+            set_cmd['cookSetTemp'] = set_temp
+        else:
+            set_cmd = self._cmd_api_base
+        set_cmd['cookStatus'] = status
+        cmd = {'cookMode': set_cmd}
+        return self._status_api(cmd)
+
+    def _status_api(self, json_cmd: dict):
+        """Set API status with jsonCmd."""
+        body = self.get_status_body(json_cmd)
+        url = '/cloud/v1/deviceManaged/bypass'
+        resp, _ = helpers.call_api(url, 'post', json_object=body)
+        if resp is None:
+            logger.debug('Failed to set status for %s - No response from API',
+                         self.device_name)
+            return False
+
+        if resp.get('code') != 0 and resp.get('code') is not None:
+            debug_msg = self.fryer_code_check(resp.get('code'))
+            logger.debug('Failed to set status for %s \n Code: %s and message: %s \n'
+                         ' %s', self.device_name, resp.get("code"),
+                         resp.get("msg"), debug_msg)
+            return False
+        self.last_update = int(time.time())
+        self.fryer_status.status_request(json_cmd)
+        self.update()
+        return True
+
+    def displayJSON(self) -> str:
+        """Display JSON of device details."""
+        sup = super().displayJSON()
+        sup_dict = json.loads(sup)
+        sup_dict['cook_status'] = self.cook_status
+        sup_dict['temp_unit'] = self.temp_unit
+
+        if self.cook_status not in ['standby', 'cookEnd', 'preheatEnd']:
+            status_dict = {
+                'preheat': self.preheat,
+                'current_temp': self.current_temp,
+                'cook_set_temp': self.cook_set_temp,
+                'cook_set_time': self.cook_set_time,
+                'cook_last_time': self.cook_last_time
+            }
+            if self.preheat is True:
+                preheat_dict = {
+                    'preheat_last_time': self.preheat_last_time,
+                    'preheat_set_time': self.preheat_set_time
+                }
+                status_dict.update(preheat_dict)
+            sup_dict.update(status_dict)
+        return json.dumps(sup_dict, indent=4)
diff --git a/src/pyvesync/vesyncswitch.py b/src/pyvesync/vesyncswitch.py
index 2f059eb..af06fc0 100644
--- a/src/pyvesync/vesyncswitch.py
+++ b/src/pyvesync/vesyncswitch.py
@@ -3,7 +3,7 @@
 import logging
 import json
 from abc import ABCMeta, abstractmethod
-from typing import Dict, Union
+from typing import Dict, Union, Optional
 
 from pyvesync.helpers import Helpers as helpers
 from pyvesync.vesyncbasedevice import VeSyncBaseDevice
@@ -42,7 +42,7 @@ def __init__(self, details, manager):
         self.features = feature_dict.get(self.device_type, {}).get('features')
         if self.features is None:
             logger.error('% device configuration not set', self.device_name)
-            raise Exception
+            raise KeyError(f'Device configuration not set {self.device_name}')
         self.details = {}
 
     def is_dimmable(self) -> bool:
@@ -273,10 +273,10 @@ def indicator_light_off(self) -> bool:
         """Turn indicator light off."""
         return self.indicator_light_toggle('off')
 
-    def rgb_color_status(
-            self, status: str,
-            red: int = None, blue: int = None, green: int = None
-    ) -> bool:
+    def rgb_color_status(self, status: str,
+                         red: Optional[int] = None,
+                         blue: Optional[int] = None,
+                         green: Optional[int] = None) -> bool:
         """Set faceplate RGB color."""
         body = helpers.req_body(self.manager, 'devicestatus')
         body['status'] = status
@@ -308,6 +308,12 @@ def rgb_color_on(self) -> bool:
 
     def rgb_color_set(self, red: int, green: int, blue: int) -> bool:
         """Set RGB color of faceplate."""
+        try:
+            red = int(red)
+            green = int(green)
+            blue = int(blue)
+        except ValueError:
+            return False
         if isinstance(red, int) and isinstance(
                 green, int) and isinstance(blue, int):
             for color in [red, green, blue]:
@@ -344,7 +350,7 @@ def set_brightness(self, brightness: int) -> bool:
     def displayJSON(self) -> str:
         """JSON API for dimmer switch."""
         sup_val = json.loads(super().displayJSON())
-        if self.is_dimmable:  # pylint: disable=using-constant-test
+        if self.is_dimmable is True:  # pylint: disable=using-constant-test
             sup_val.update(
                 {
                     'Indicator Light': str(self.active_time),
diff --git a/tox.ini b/tox.ini
index 2fc34a4..d448b71 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,10 +1,13 @@
 [tox]
-envlist = py38, pylint, lint, mypy
+envlist = py39, pylint, lint, mypy
 skip_missing_interpreters = True
+ignore_basepython_conflict = True
 
 [testenv]
-basepython = {env:PYTHON3_PATH:python3}
-deps = pytest
+basepython = {env:PYTHON3_PATH:python39}
+deps =
+    pytest
+    pyyaml
 commands = pytest -q {posargs}
 
 [testenv:pylint]


Test patch:
diff --git a/src/tests/README.md b/src/tests/README.md
new file mode 100644
index 0000000..a23374d
--- /dev/null
+++ b/src/tests/README.md
@@ -0,0 +1,210 @@
+# The pyvesync testing library
+
+This is the testing suite for the pyvesync library. Each device that is added must include tests. This helps to maintain the consistency of the API as new devices are added and the backend is refactored.
+
+I've built a relatively simple framework to ease the burden of writing tests. There are some old tests that I had previously written that I've kept as I build the new framework but these tests were not comprehensive or portable. The files that begin with `test_x_` are these previous tests and can safely be ignored.
+
+The tests primarily run each API call for each device and record the request details in YAML files in the `tests\API` directory. These files are then used to verify the request when the test is run again.
+
+The structure of the framework is as follows:
+
+1. `call_json.py` - This file contains general functions and the device list builder. This file does not need to be edited when adding a device.
+2. `call_json_DEVICE.py` - This file contains device specific responses such as the `get_details()` response and specific method responses. This file pulls in the device type list from each module. The minimum addition is to add the appropriate response to the `DeviceDetails` class and the device type associated with that response in the `DETAILS_RESPONSES` dictionary.
+3. `test_DEVICE.py`  - Each module in pyvesync has it's own test module, typically with one class that inherits the `utils.BaseTest` class. The class has two methods - `test_details()` and `test_methods()` that are parametrized by `utils.pytest_generate_tests`
+4. `utils.py` - Contains the general default values for all devices in the `Defaults` class and the `TestBase` class that contains the fixture that instantiates the VS object and patches the `call_api()` method.
+5. `conftest.py` - Contains the `pytest_generate_tests` function that is used to parametrize the tests.
+
+## Testing Process
+
+The first test run verifies that all of the devices defined in each pyvesync module have a corresponding response in each `call_json_DEVICE` module. This verifies that when a new device is added, a corresponding response is added to be tested.
+
+The testing framework takes the approach of verifying the response and request of each API call separately. The request side of the call is verified by recording the request for a mocked call. The requests are recorded into YAML files in the `api` folder of the tests directory, grouped in folders by module and file by device type.
+
+The response side of the API is tested through the use of responses that have been documented in the `call_json` files.
+
+## File Structure
+
+### Device Responses and Details
+
+The call_json files contain all of the response data for each device type. The following call_json files are included in the test directory:
+
+   - `call_json.py` - general API responses, including `login()` and `get_devices()`. The device list from the `get_devices()` can be used to create the device list response for all devices.
+   - `call_json_outlets.py` - API responses for the outlets
+   - `call_json_switches.py` - API responses for the switches
+   - `call_json_fans.py` - API responses for the fans
+   - `call_json_bulbs.py` - API responses for the bulbs
+
+#### call_json.py
+
+The `call_json.py` file contains the functions to build the `get_devices()` response containing the device list and each item on the device list. The `DeviceList` class contains the `device_list_response(devices_types=None, _types=None)` method which returns the full device list response based on the defined device types (model number(s)) or types (outlets, fans, etc.). The `device_list_item(model)` builds the individual device list item that is used to instantiate the device object. The default values for device configuration values are pulled from the `Defaults` class in the `utils.py` module for consistency.
+
+#### call_json_DEVICE.py
+
+Each device module has it's own call_json file. The structure of the files maintains a consistency for easy test definition. The `DeviceDetails` (SwitchDetails, OutletDetails) class contains the `get_details()` responses for each device as a class attribute. The name of the class attribute does not matter.
+
+The `DETAILS_RESPONSES` dictionary contains the device type as the key and references the `DeviceDetails` class attribute as the value. The `DETAILS_RESPONSES` dictionary is used to lookup the appropriate response for each device type.
+
+The responses for device methods are also defined in the `call_json_DEVICE` module. The METHOD_RESPONSES dictionary uses a defaultdict imported from `utils.py` with a simple `{"code": 0, "message": ""}` as the default value. The `METHOD_RESPONSES` dictionary is created with keys of device type and values as the defaultdict object. From here the method responses can be added to the defaultdict object for specific scenarios.
+
+```python
+from utils import FunctionResponses
+from copy import deepcopy
+
+device_types = ['dev1', 'dev2']
+
+method_response = FunctionResponses
+
+device_responses = {dev_type: deepcopy(method_response) for dev_type in device_types}
+
+# Define response for specific device & method
+device_responses['dev1']['special_method'] = ({'response': 'special response', 'msg': 'special method'}, 200)
+
+# The default factory can be change for a single device type since deepcopy is used.
+device_responses['dev2'].default_factory = lambda: ({'new_code': 0, 'msg': 'success', {'payload': {}}}, 200)
+
+```
+
+The method responses can also be a function that accept one argument that contains the kwargs used in the method call. This allows for more complex responses based on the method call.
+
+For example, this is the set status response of the valceno bulb:
+
+```python
+
+METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in BULBS}
+
+def valceno_set_status_response(kwargs=None):
+    default_resp = {
+        "traceId": Defaults.trace_id,
+        "code": 0,
+        "msg": "request success",
+        "result": {
+            "traceId": Defaults.trace_id,
+            "code": 0,
+            "result": {
+                "enabled": "on",
+                "colorMode": "hsv",
+                "brightness": Defaults.brightness,
+                "colorTemp": Defaults.color_temp,
+                "hue": Defaults.color.hsv.hue*27.7778,
+                "saturation": Defaults.color.hsv.saturation*100,
+                "value": Defaults.color.hsv.value
+            }
+        }
+    }
+    if kwargs is not None and isinstance(kwargs, dict):
+        if kwargs.get('hue') is not None:
+            default_resp['result']['result']['hue'] = kwargs['hue'] * 27.7778
+        if kwargs.get('saturation') is not None:
+            default_resp['result']['result']['saturation'] = kwargs['saturation'] * 100
+        if kwargs.get('value') is not None:
+            default_resp['result']['result']['value'] = kwargs['value']
+    return default_resp, 200
+
+
+XYD0001_RESP = {
+    'set_brightness': valceno_set_status_response,
+    'set_color_temp': valceno_set_status_response,
+    'set_hsv': valceno_set_status_response,
+    'set_rgb': valceno_set_status_response,
+}
+
+METHOD_RESPONSES['XYD0001'].update(XYD0001_RESP)
+
+
+```
+
+### **`api`** directory with `YAML` files
+
+API requests recorded from the mocked `call_api()` method. The first time a test is run, it will record the request if it does not already exists. The `api` directory contains folders for each module and files for each device_type. The structure of the YAML files is:
+
+**File** `tests/api/switches/esl100.yaml`
+```yaml
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    switchNo: 3
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESO15-TB-UUID
+  method: put
+  url: /outdoorsocket15a/v1/device/devicestatus
+```
+
+### **`utils.py`** - utility functions and default value factory for tests.
+
+The `utils.py`  file contains several helper functions and classes:
+
+**Default values for API responses and requests**
+
+The recorded requests are automatically scrubbed with these default values to remove sensitive information and normalize the data. Any new API calls added to `call_json_` files should use the defaults values wherever possible.
+
+```python
+from utils import Defaults
+
+# Default Class variables
+token = Defaults.token
+account_id = Defaults.account_id
+trace_id = Defaults.trace_id
+active_time = Defaults.active_time
+# The default Color dataclass contains the attributes red, green, blue, hue saturation & value. Conversion is automatically done regardless of the input color model. This is to normalize any API calls that involve changing color
+color = Color(red=50, green=100, blue=255)
+brightness = Defaults.brightness
+color_temp = Defaults.color_temp
+
+# Default values that use methods
+device_name = Defaults.name(dev_type="ESL100") # returns 'ESL100-NAME'
+device_cid = Defaults.cid(dev_type="ESL100") # returns 'ESL100-CID'
+device_uuid = Defaults.uuid(dev_type="ESL100") # returns 'ESL100-UUID'
+device_mac = Defaults.macid(dev_type="ESL100") # returns 'ESL100-MACID'
+```
+
+The `utils` module contains the base class with a fixture that instantiates the VeSync object and patches `call_api()` automatically.
+
+```python
+from utils import TestBase, FunctionResponses
+
+
+class TestDevice(TestBase):
+
+    def test_details(self):
+        vesync_instance = self.manager
+        mock_api_object = self.mock_api # patch('pyvesync.helpers.call_api', autopspec=True)
+        mock_api_object.return_value = FunctionResponses['default']
+        caplog = self.caplog
+        assert vesync_instance.enabled is True
+
+```
+
+## Test Structure
+
+Each module in the pyvesync library has an associated testing module, for example, `vesyncswitches` and `test_switches`. Most testing modules have one class, except for the `test_fans` module, which has separate classes for humidifiers and air purifiers.
+
+The class inherits from the `TestBase` class in `utils.py` and is parametrized by `pytest_generate_tests` based on the method. The parameters are defined by the class attributes. The `base_methods` and `device_methods` class attributes define the method and arguments in a list of lists with the first item, the method name and the second optional item, the method kwargs. The `base_methods` class attribute defines methods that are common to all devices. The `device_methods` class attribute defines methods that are specific to the device type.
+
+This is an examples of the class definition:
+
+```python
+from utils import TestBase
+
+class TestBulbs(TestBase):
+    device = 'bulbs'
+    bulbs = call_json_bulbs.BULBS
+      base_methods = [['turn_on'], ['turn_off'],
+                      ['set_brightness',   {'brightness': 50}]]
+    device_methods = {
+        'ESL100CW': [['set_color_temp', {'color_temp': 50}]]
+    }
+```
+
+The methods are then parametrized based on those values. For most device additions, the only thing that needs to be added is the device type in the `DETAILS_RESPONSES` and possibly a response in the `METHOD_RESPONSES` dictionary.
+
+See the docstrings in the modules for more information.
\ No newline at end of file
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
deleted file mode 100644
index 5070bad..0000000
--- a/src/tests/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""pyvesync test scripts."""
diff --git a/src/tests/api/vesync/VeSync.yaml b/src/tests/api/vesync/VeSync.yaml
new file mode 100644
index 0000000..77021a9
--- /dev/null
+++ b/src/tests/api/vesync/VeSync.yaml
@@ -0,0 +1,33 @@
+get_devices:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devices
+    pageNo: '1'
+    pageSize: '100'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v1/deviceManaged/devices
+login:
+  json_object:
+    acceptLanguage: en
+    appVersion: 2.8.6
+    devToken: ''
+    email: EMAIL
+    method: login
+    password: PASSWORD
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    traceId: TRACE_ID
+    userType: '1'
+  method: post
+  url: /cloud/v1/user/login
diff --git a/src/tests/api/vesyncbulb/ESL100.yaml b/src/tests/api/vesyncbulb/ESL100.yaml
new file mode 100644
index 0000000..4bc7747
--- /dev/null
+++ b/src/tests/api/vesyncbulb/ESL100.yaml
@@ -0,0 +1,74 @@
+set_brightness:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    brightNess: '50'
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESL100-UUID
+  method: put
+  url: /SmartBulb/v1/device/updateBrightness
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESL100-UUID
+  method: put
+  url: /SmartBulb/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESL100-UUID
+  method: put
+  url: /SmartBulb/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESL100-UUID
+  method: post
+  url: /SmartBulb/v1/device/devicedetail
diff --git a/src/tests/api/vesyncbulb/ESL100CW.yaml b/src/tests/api/vesyncbulb/ESL100CW.yaml
new file mode 100644
index 0000000..6740045
--- /dev/null
+++ b/src/tests/api/vesyncbulb/ESL100CW.yaml
@@ -0,0 +1,123 @@
+set_brightness:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100CW-CID
+    configModule: ConfigModule
+    jsonCmd:
+      light:
+        brightness: 50
+    method: bypass
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v1/deviceManaged/bypass
+set_color_temp:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100CW-CID
+    jsonCmd:
+      light:
+        colorTempe: 50
+    method: bypass
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v1/deviceManaged/bypass
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100CW-CID
+    configModule: ConfigModule
+    jsonCmd:
+      light:
+        action: 'off'
+    method: bypass
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v1/deviceManaged/bypass
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100CW-CID
+    configModule: ConfigModule
+    jsonCmd:
+      light:
+        action: 'on'
+    method: bypass
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v1/deviceManaged/bypass
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100CW-CID
+    configModule: ConfigModule
+    jsonCmd:
+      getLightStatus: get
+    method: bypass
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v1/deviceManaged/bypass
diff --git a/src/tests/api/vesyncbulb/ESL100MC.yaml b/src/tests/api/vesyncbulb/ESL100MC.yaml
new file mode 100644
index 0000000..da73527
--- /dev/null
+++ b/src/tests/api/vesyncbulb/ESL100MC.yaml
@@ -0,0 +1,169 @@
+enable_white_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100MC-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        action: 'on'
+        blue: 0
+        brightness: 100
+        colorMode: white
+        green: 0
+        red: 0
+        speed: 0
+      method: setLightStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_brightness:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100MC-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        action: 'on'
+        blue: 0
+        brightness: 50
+        colorMode: white
+        green: 0
+        red: 0
+        speed: 0
+      method: setLightStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_rgb:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100MC-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        action: 'on'
+        blue: 255
+        brightness: 100
+        colorMode: color
+        green: 200
+        red: 50
+        speed: 0
+      method: setLightStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100MC-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100MC-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: ESL100MC-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getLightStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncbulb/XYD0001.yaml b/src/tests/api/vesyncbulb/XYD0001.yaml
new file mode 100644
index 0000000..f697eac
--- /dev/null
+++ b/src/tests/api/vesyncbulb/XYD0001.yaml
@@ -0,0 +1,193 @@
+enable_white_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        brightness: ''
+        colorMode: white
+        colorTemp: ''
+        force: 1
+        hue: ''
+        saturation: ''
+        value: ''
+      method: setLightStatusV2
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_brightness:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        brightness: 50
+        colorMode: ''
+        colorTemp: ''
+        force: 0
+        hue: ''
+        saturation: ''
+        value: ''
+      method: setLightStatusV2
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_color_temp:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        brightness: ''
+        colorMode: white
+        colorTemp: 50
+        force: 1
+        hue: ''
+        saturation: ''
+        value: ''
+      method: setLightStatusV2
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_hsv:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        brightness: 100
+        colorMode: hsv
+        colorTemp: ''
+        force: 1
+        hue: 5556
+        saturation: 5000
+        value: ''
+      method: setLightStatusV2
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: XYD0001-CID
+    configModule: ConfigModule
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getLightStatusV2
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Classic200S.yaml b/src/tests/api/vesyncfan/Classic200S.yaml
new file mode 100644
index 0000000..2e18530
--- /dev/null
+++ b/src/tests/api/vesyncfan/Classic200S.yaml
@@ -0,0 +1,280 @@
+automatic_stop_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+automatic_stop_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: auto
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_humidity:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        target_humidity: 50
+      method: setTargetHumidity
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: manual
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_mist_level:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 2
+        type: mist
+      method: setVirtualLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setIndicatorLightSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setIndicatorLightSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getHumidifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Classic300S.yaml b/src/tests/api/vesyncfan/Classic300S.yaml
new file mode 100644
index 0000000..5c68c1d
--- /dev/null
+++ b/src/tests/api/vesyncfan/Classic300S.yaml
@@ -0,0 +1,278 @@
+automatic_stop_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+automatic_stop_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: auto
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_humidity:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        target_humidity: 50
+      method: setTargetHumidity
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: manual
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_mist_level:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 2
+        type: mist
+      method: setVirtualLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Classic300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getHumidifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Core200S.yaml b/src/tests/api/vesyncfan/Core200S.yaml
new file mode 100644
index 0000000..203d3c8
--- /dev/null
+++ b/src/tests/api/vesyncfan/Core200S.yaml
@@ -0,0 +1,206 @@
+change_fan_speed:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 3
+        mode: manual
+        type: wind
+      method: setLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 1
+        type: wind
+      method: setLevel
+      type: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+sleep_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: sleep
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getPurifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Core300S.yaml b/src/tests/api/vesyncfan/Core300S.yaml
new file mode 100644
index 0000000..3f71d15
--- /dev/null
+++ b/src/tests/api/vesyncfan/Core300S.yaml
@@ -0,0 +1,231 @@
+auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: auto
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+change_fan_speed:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 3
+        mode: manual
+        type: wind
+      method: setLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 1
+        type: wind
+      method: setLevel
+      type: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+sleep_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: sleep
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core300S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getPurifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Core400S.yaml b/src/tests/api/vesyncfan/Core400S.yaml
new file mode 100644
index 0000000..8ed3d56
--- /dev/null
+++ b/src/tests/api/vesyncfan/Core400S.yaml
@@ -0,0 +1,231 @@
+auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: auto
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+change_fan_speed:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 3
+        mode: manual
+        type: wind
+      method: setLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 1
+        type: wind
+      method: setLevel
+      type: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+sleep_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: sleep
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core400S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getPurifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Core600S.yaml b/src/tests/api/vesyncfan/Core600S.yaml
new file mode 100644
index 0000000..c3a1d83
--- /dev/null
+++ b/src/tests/api/vesyncfan/Core600S.yaml
@@ -0,0 +1,231 @@
+auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: auto
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+change_fan_speed:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 3
+        mode: manual
+        type: wind
+      method: setLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 1
+        type: wind
+      method: setLevel
+      type: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+sleep_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: sleep
+      method: setPurifierMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Core600S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getPurifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/Dual200S.yaml b/src/tests/api/vesyncfan/Dual200S.yaml
new file mode 100644
index 0000000..f2aa945
--- /dev/null
+++ b/src/tests/api/vesyncfan/Dual200S.yaml
@@ -0,0 +1,278 @@
+automatic_stop_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+automatic_stop_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: auto
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_humidity:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        target_humidity: 50
+      method: setTargetHumidity
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: manual
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_mist_level:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 2
+        type: mist
+      method: setVirtualLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: Dual200S-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getHumidifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/LUH-A602S-WUSR.yaml b/src/tests/api/vesyncfan/LUH-A602S-WUSR.yaml
new file mode 100644
index 0000000..3092392
--- /dev/null
+++ b/src/tests/api/vesyncfan/LUH-A602S-WUSR.yaml
@@ -0,0 +1,305 @@
+automatic_stop_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+automatic_stop_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: humidity
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_humidity:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        target_humidity: 50
+      method: setTargetHumidity
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: manual
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_mist_level:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 2
+        type: mist
+      method: setVirtualLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_warm_level:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 3
+        type: warm
+      method: setLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-A602S-WUSR-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getHumidifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/LUH-O451S-WUS.yaml b/src/tests/api/vesyncfan/LUH-O451S-WUS.yaml
new file mode 100644
index 0000000..179b670
--- /dev/null
+++ b/src/tests/api/vesyncfan/LUH-O451S-WUS.yaml
@@ -0,0 +1,278 @@
+automatic_stop_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+automatic_stop_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+      method: setAutomaticStop
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_auto_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: humidity
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_humidity:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        target_humidity: 50
+      method: setTargetHumidity
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_manual_mode:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        mode: manual
+      method: setHumidityMode
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+set_mist_level:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        id: 0
+        level: 2
+        type: mist
+      method: setVirtualLevel
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: false
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_off_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: false
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        enabled: true
+        id: 0
+      method: setSwitch
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+turn_on_display:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data:
+        state: true
+      method: setDisplay
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
+update:
+  headers:
+    Content-Type: application/json; charset=UTF-8
+    User-Agent: okhttp/3.12.1
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    cid: LUH-O451S-WUS-CID
+    configModule: ConfigModule
+    debugMode: false
+    deviceRegion: US
+    method: bypassV2
+    payload:
+      data: {}
+      method: getHumidifierStatus
+      source: APP
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+  method: post
+  url: /cloud/v2/deviceManaged/bypassV2
diff --git a/src/tests/api/vesyncfan/LV-PUR131S.yaml b/src/tests/api/vesyncfan/LV-PUR131S.yaml
new file mode 100644
index 0000000..a063bbf
--- /dev/null
+++ b/src/tests/api/vesyncfan/LV-PUR131S.yaml
@@ -0,0 +1,108 @@
+change_fan_speed:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    level: 3
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: LV-PUR131S-UUID
+  method: put
+  url: /131airPurifier/v1/device/updateSpeed
+manual_mode:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    level: 1
+    mode: manual
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: LV-PUR131S-UUID
+  method: put
+  url: /131airPurifier/v1/device/updateMode
+sleep_mode:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    mode: sleep
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: LV-PUR131S-UUID
+  method: put
+  url: /131airPurifier/v1/device/updateMode
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: LV-PUR131S-UUID
+  method: put
+  url: /131airPurifier/v1/device/deviceStatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: LV-PUR131S-UUID
+  method: put
+  url: /131airPurifier/v1/device/deviceStatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: LV-PUR131S-UUID
+  method: post
+  url: /131airPurifier/v1/device/deviceDetail
diff --git a/src/tests/api/vesyncoutlet/ESO15-TB.yaml b/src/tests/api/vesyncoutlet/ESO15-TB.yaml
new file mode 100644
index 0000000..c753b00
--- /dev/null
+++ b/src/tests/api/vesyncoutlet/ESO15-TB.yaml
@@ -0,0 +1,124 @@
+get_monthly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energymonth
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESO15-TB-UUID
+  method: post
+  url: /outdoorsocket15a/v1/device/energymonth
+get_weekly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyweek
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESO15-TB-UUID
+  method: post
+  url: /outdoorsocket15a/v1/device/energyweek
+get_yearly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyyear
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESO15-TB-UUID
+  method: post
+  url: /outdoorsocket15a/v1/device/energyyear
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    switchNo: 1
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESO15-TB-UUID
+  method: put
+  url: /outdoorsocket15a/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    switchNo: 1
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESO15-TB-UUID
+  method: put
+  url: /outdoorsocket15a/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESO15-TB-UUID
+  method: post
+  url: /outdoorsocket15a/v1/device/devicedetail
diff --git a/src/tests/api/vesyncoutlet/ESW01-EU.yaml b/src/tests/api/vesyncoutlet/ESW01-EU.yaml
new file mode 100644
index 0000000..de17af5
--- /dev/null
+++ b/src/tests/api/vesyncoutlet/ESW01-EU.yaml
@@ -0,0 +1,122 @@
+get_monthly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energymonth
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW01-EU-UUID
+  method: post
+  url: /10a/v1/device/energymonth
+get_weekly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyweek
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW01-EU-UUID
+  method: post
+  url: /10a/v1/device/energyweek
+get_yearly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyyear
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW01-EU-UUID
+  method: post
+  url: /10a/v1/device/energyyear
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW01-EU-UUID
+  method: put
+  url: /10a/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW01-EU-UUID
+  method: put
+  url: /10a/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW01-EU-UUID
+  method: post
+  url: /10a/v1/device/devicedetail
diff --git a/src/tests/api/vesyncoutlet/ESW03-USA.yaml b/src/tests/api/vesyncoutlet/ESW03-USA.yaml
new file mode 100644
index 0000000..b2505ac
--- /dev/null
+++ b/src/tests/api/vesyncoutlet/ESW03-USA.yaml
@@ -0,0 +1,122 @@
+get_monthly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energymonth
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW03-USA-UUID
+  method: post
+  url: /10a/v1/device/energymonth
+get_weekly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyweek
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW03-USA-UUID
+  method: post
+  url: /10a/v1/device/energyweek
+get_yearly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyyear
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW03-USA-UUID
+  method: post
+  url: /10a/v1/device/energyyear
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW03-USA-UUID
+  method: put
+  url: /10a/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW03-USA-UUID
+  method: put
+  url: /10a/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW03-USA-UUID
+  method: post
+  url: /10a/v1/device/devicedetail
diff --git a/src/tests/api/vesyncoutlet/ESW15-USA.yaml b/src/tests/api/vesyncoutlet/ESW15-USA.yaml
new file mode 100644
index 0000000..94cf9a9
--- /dev/null
+++ b/src/tests/api/vesyncoutlet/ESW15-USA.yaml
@@ -0,0 +1,156 @@
+get_monthly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energymonth
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW15-USA-UUID
+  method: post
+  url: /15a/v1/device/energymonth
+get_weekly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyweek
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW15-USA-UUID
+  method: post
+  url: /15a/v1/device/energyweek
+get_yearly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: energyyear
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW15-USA-UUID
+  method: post
+  url: /15a/v1/device/energyyear
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW15-USA-UUID
+  method: put
+  url: /15a/v1/device/devicestatus
+turn_off_nightlight:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    mode: manual
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW15-USA-UUID
+  method: put
+  url: /15a/v1/device/nightlightstatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW15-USA-UUID
+  method: put
+  url: /15a/v1/device/devicestatus
+turn_on_nightlight:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    mode: auto
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESW15-USA-UUID
+  method: put
+  url: /15a/v1/device/nightlightstatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESW15-USA-UUID
+  method: post
+  url: /15a/v1/device/devicedetail
diff --git a/src/tests/api/vesyncoutlet/wifi-switch-1.3.yaml b/src/tests/api/vesyncoutlet/wifi-switch-1.3.yaml
new file mode 100644
index 0000000..8febb20
--- /dev/null
+++ b/src/tests/api/vesyncoutlet/wifi-switch-1.3.yaml
@@ -0,0 +1,60 @@
+get_monthly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  method: get
+  url: /v1/device/wifi-switch-1.3-CID/energy/month
+get_weekly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  method: get
+  url: /v1/device/wifi-switch-1.3-CID/energy/week
+get_yearly_energy:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  method: get
+  url: /v1/device/wifi-switch-1.3-CID/energy/year
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  method: put
+  url: /v1/wifi-switch-1.3/wifi-switch-1.3-CID/status/off
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  method: put
+  url: /v1/wifi-switch-1.3/wifi-switch-1.3-CID/status/on
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  method: get
+  url: /v1/device/wifi-switch-1.3-CID/detail
diff --git a/src/tests/api/vesyncswitch/ESWD16.yaml b/src/tests/api/vesyncswitch/ESWD16.yaml
new file mode 100644
index 0000000..b8e83dd
--- /dev/null
+++ b/src/tests/api/vesyncswitch/ESWD16.yaml
@@ -0,0 +1,128 @@
+indicator_light_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWD16-UUID
+  method: put
+  url: /dimmer/v1/device/indicatorlightstatus
+rgb_color_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWD16-UUID
+  method: put
+  url: /dimmer/v1/device/devicergbstatus
+rgb_color_set:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    rgbValue:
+      blue: 100
+      green: 225
+      red: 50
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWD16-UUID
+  method: put
+  url: /dimmer/v1/device/devicergbstatus
+set_brightness:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    brightness: 100
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWD16-UUID
+  method: put
+  url: /dimmer/v1/device/updatebrightness
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWD16-UUID
+  method: put
+  url: /dimmer/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWD16-UUID
+  method: put
+  url: /dimmer/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESWD16-UUID
+  method: post
+  url: /dimmer/v1/device/devicedetail
diff --git a/src/tests/api/vesyncswitch/ESWL01.yaml b/src/tests/api/vesyncswitch/ESWL01.yaml
new file mode 100644
index 0000000..cf746fa
--- /dev/null
+++ b/src/tests/api/vesyncswitch/ESWL01.yaml
@@ -0,0 +1,56 @@
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWL01-UUID
+  method: put
+  url: /inwallswitch/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWL01-UUID
+  method: put
+  url: /inwallswitch/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESWL01-UUID
+  method: post
+  url: /inwallswitch/v1/device/devicedetail
diff --git a/src/tests/api/vesyncswitch/ESWL03.yaml b/src/tests/api/vesyncswitch/ESWL03.yaml
new file mode 100644
index 0000000..66393fc
--- /dev/null
+++ b/src/tests/api/vesyncswitch/ESWL03.yaml
@@ -0,0 +1,56 @@
+turn_off:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'off'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWL03-UUID
+  method: put
+  url: /inwallswitch/v1/device/devicestatus
+turn_on:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    status: 'on'
+    timeZone: America/New_York
+    token: sample_tk
+    uuid: ESWL03-UUID
+  method: put
+  url: /inwallswitch/v1/device/devicestatus
+update:
+  headers:
+    accept-language: en
+    accountId: sample_id
+    appVersion: 2.8.6
+    content-type: application/json
+    tk: sample_tk
+    tz: America/New_York
+  json_object:
+    acceptLanguage: en
+    accountID: sample_id
+    appVersion: 2.8.6
+    method: devicedetail
+    mobileId: '1234567890123456'
+    phoneBrand: SM N9005
+    phoneOS: Android
+    timeZone: America/New_York
+    token: sample_tk
+    traceId: TRACE_ID
+    uuid: ESWL03-UUID
+  method: post
+  url: /inwallswitch/v1/device/devicedetail
diff --git a/src/tests/call_json.py b/src/tests/call_json.py
index a2c9e8b..6f6d760 100644
--- a/src/tests/call_json.py
+++ b/src/tests/call_json.py
@@ -1,5 +1,10 @@
-import time
+import copy
 import pyvesync.helpers as helpers
+from utils import Defaults
+import call_json_switches
+import call_json_outlets
+import call_json_bulbs
+import call_json_fans
 
 API_BASE_URL = helpers.API_BASE_URL
 API_RATE_LIMIT = helpers.API_RATE_LIMIT
@@ -11,9 +16,7 @@
 MOBILE_ID = helpers.MOBILE_ID
 USER_TYPE = helpers.USER_TYPE
 
-SAMPLE_ACTID = 'sample_id'
-SAMPLE_TOKEN = 'sample_tk'
-TRACE_ID = str(int(time.time()))
+
 """
 DEFAULT_BODY = Standard body for new device calls
 DEFAULT_HEADER = standard header for most calls
@@ -54,12 +57,18 @@
 
 """
 
+BULBS = call_json_bulbs.BULBS
+FANS = call_json_fans.FANS
+OUTLETS = call_json_outlets.OUTLETS
+SWITCHES = call_json_switches.SWITCHES
+
+
 DEFAULT_HEADER = {
     'accept-language': 'en',
-    'accountId': SAMPLE_ACTID,
+    'accountId': Defaults.account_id,
     'appVersion': APP_VERSION,
     'content-type': 'application/json',
-    'tk': SAMPLE_TOKEN,
+    'tk': Defaults.token,
     'tz': DEFAULT_TZ,
 }
 
@@ -68,62 +77,50 @@
     'User-Agent': 'okhttp/3.12.1'
 }
 
+
 def BYPASS_V1_BODY(cid: str, config_module: str, json_cmd: dict):
     return {
-	"traceId": TRACE_ID,
-	"method": "bypass",
-	"token": SAMPLE_TOKEN,
-	"accountID": SAMPLE_ACTID,
-	"timeZone": DEFAULT_TZ,
-	"acceptLanguage": "en",
-	"appVersion": APP_VERSION,
-	"phoneBrand": PHONE_BRAND,
-	"phoneOS": PHONE_OS,
-	"cid": cid,
-	"configModule": config_module,
-	"jsonCmd": json_cmd
-}
+        "traceId": Defaults.trace_id,
+        "method": "bypass",
+        "token": Defaults.token,
+        "accountID": Defaults.account_id,
+        "timeZone": DEFAULT_TZ,
+        "acceptLanguage": "en",
+        "appVersion": APP_VERSION,
+        "phoneBrand": PHONE_BRAND,
+        "phoneOS": PHONE_OS,
+        "cid": cid,
+        "configModule": config_module,
+        "jsonCmd": json_cmd
+    }
+
 
 DEFAULT_BODY = {
     'acceptLanguage': 'en',
-    'accountID': SAMPLE_ACTID,
+    'accountID': Defaults.account_id,
     'appVersion': APP_VERSION,
     'pageNo': 1,
     'pageSize': 100,
     'phoneBrand': PHONE_BRAND,
     'phoneOS': PHONE_OS,
     'timeZone': DEFAULT_TZ,
-    'token': SAMPLE_TOKEN,
-    'traceId': TRACE_ID,
+    'token': Defaults.token,
+    'traceId': Defaults.trace_id,
 }
 
-ENERGY_HISTORY = (
-    {
-        'code': 0,
-        'energyConsumptionOfToday': 1,
-        'costPerKWH': 1,
-        'maxEnergy': 1,
-        'totalEnergy': 1,
-        'data': [
-            1,
-            1,
-        ],
-    },
-    200,
-)
 
 LOGIN_RET_BODY = (
     {
-        'traceId': TRACE_ID,
+        'traceId': Defaults.trace_id,
         'msg': '',
         'result': {
-            'accountID': SAMPLE_ACTID,
+            'accountID': Defaults.account_id,
             'avatarIcon': '',
             'acceptLanguage': 'en',
             'gdprStatus': True,
             'nickName': 'nick',
             'userType': '1',
-            'token': SAMPLE_TOKEN,
+            'token': Defaults.token,
         },
         'code': 0,
     },
@@ -142,13 +139,88 @@ def login_call_body(email, password):
         'phoneBrand': PHONE_BRAND,
         'phoneOS': PHONE_OS,
         'timeZone': DEFAULT_TZ,
-        'traceId': TRACE_ID,
+        'traceId': Defaults.trace_id,
         'userType': '1',
     }
     return json_object
 
 
 class DeviceList:
+    list_response_base = {
+        'code': 0,
+        'msg': 'Success',
+        'result': {
+            'pageNo': 1,
+            'pageSize': 100,
+            'total': 0,
+            'list': [],
+        }
+    }
+    device_list_base = {
+        'extension': None,
+        'isOwner': True,
+        'authKey': None,
+        'deviceStatus': 'on',
+        'connectionStatus': 'online',
+        'connectionType': 'wifi',
+        'mode': None,
+        'speed': None,
+        'deviceProps': None,
+        'configModule': 'ConfigModule',
+    }
+
+    bulbs = dict.fromkeys(call_json_bulbs.BULBS, "wifi-light")
+    outlets = dict.fromkeys(call_json_outlets.OUTLETS, "wifi-switch")
+    fans = dict.fromkeys(call_json_fans.FANS, "wifi-air")
+    switches = dict.fromkeys(call_json_switches.SWITCHES, "Switches")
+
+    @classmethod
+    def device_list_item(cls, model, sub_device_no=0):
+        model_types = {**cls.bulbs, **cls.outlets, **cls.fans, **cls.switches}
+
+        device_dict = cls.device_list_base
+        model_dict = device_dict.copy()
+        model_dict['deviceType'] = model
+        model_dict['deviceName'] = Defaults.name(model)
+        model_dict['type'] = model_types.get(model)
+        model_dict['cid'] = Defaults.cid(model)
+        model_dict['uuid'] = Defaults.uuid(model)
+        model_dict['macID'] = Defaults.macid(model)
+        if model == 'ESO15-TB':
+            model_dict['subDeviceNo'] = 1
+        return model_dict
+
+    @classmethod
+    def device_list_response(cls, device_types=None, _types=None):
+        """Class method that returns the api get_devices response
+
+        Args:
+            _types (list, str, optional): Can be one or list of types of devices. Defaults to None.
+                can be bulb, fans, switches, outlets in list or string
+            device_types (list, str optional): List or string of device_type(s) to return. Defaults to None.
+
+        """
+
+        response_base = copy.deepcopy(cls.list_response_base)
+        if _types is not None:
+            if isinstance(_types, list):
+                full_model_list = {}
+                for _type in _types:
+                    device_types = full_model_list.update(cls.__dict__[_type])
+            else:
+                full_model_list = cls.__dict__[_types]
+        else:
+            full_model_list = {**cls.bulbs, **cls.outlets, **cls.fans, **cls.switches}
+        if device_types is not None:
+            if isinstance(device_types, list):
+                full_model_list = {k: v for k, v in full_model_list.items() if k in device_types}
+            else:
+                full_model_list = {k: v for k, v in full_model_list.items() if k == device_types}
+        for model in full_model_list:
+            response_base['result']['list'].append(cls.device_list_item(model))
+            response_base['result']['total'] += 1
+        return response_base, 200
+
     LIST_CONF_7A = {
         'deviceType': 'wifi-switch-1.3',
         'extension': None,
@@ -431,7 +503,7 @@ class DeviceList:
         "macID": None,
         "subDeviceNo": None,
         "subDeviceType": None
-      }
+    }
 
     API_URL = '/cloud/v1/deviceManaged/devices'
 
@@ -457,6 +529,8 @@ class DeviceList:
 
     ]
 
+
+
     @classmethod
     def DEVICE_LIST_RETURN(cls, dev_conf: dict) -> tuple:
         """Test the fan."""
@@ -499,6 +573,37 @@ def DEVICE_LIST_RETURN(cls, dev_conf: dict) -> tuple:
     )
 
 
+class DeviceDetails:
+    """Responses for get_details() method for all devices.
+
+    class attributes:
+    outlets : dict
+        Dictionary of outlet responses for each device type.
+    switches : dict
+        Dictionary of switch responses for each device type.
+    bulbs : dict
+        Dictionary of bulb responses for each device type.
+    fans : dict
+        Dictionary of humidifier & air pur responses for each device type.
+    all_devices : dict
+        Dictionary of all device responses for each device type.
+
+    Example
+    -------
+    outlets = {'ESW01-EU': {'switches': [{'outlet': 0, 'switch': 'on'}]}}
+    """
+
+    outlets = call_json_outlets.DETAILS_RESPONSES
+    switches = call_json_switches.DETAILS_RESPONSES
+    fans = call_json_fans.DETAILS_RESPONSES
+    bulbs = call_json_bulbs.DETAILS_RESPONSES
+    all_devices = {
+        'outlets': outlets,
+        'switches': switches,
+        'fans': fans,
+        'bulbs': bulbs
+        }
+
 
 def get_devices_body():
     """Build device body dictionary."""
@@ -507,189 +612,12 @@ def get_devices_body():
     return body, 200
 
 
-
-
-
-
-
 def get_details_body():
     body = DEFAULT_BODY
     body['method'] = 'deviceDetail'
     return body, 200
 
 
-DETAILS_15A = (
-    {
-        'code': 0,
-        'msg': None,
-        'deviceStatus': 'on',
-        'connectionStatus': 'online',
-        'activeTime': 1,
-        'energy': 1,
-        'nightLightStatus': 'on',
-        'nightLightBrightness': 50,
-        'nightLightAutomode': 'manual',
-        'power': '1',
-        'voltage': '1',
-    },
-    200,
-)
-
-DETAILS_7A = (
-    {
-        'deviceStatus': 'on',
-        'deviceImg': '',
-        'activeTime': 1,
-        'energy': 1,
-        'power': '1000:1000',
-        'voltage': '1000:1000',
-    },
-    200,
-)
-
-DETAILS_WS = (
-    {
-        'code': 0,
-        'msg': None,
-        'deviceStatus': 'on',
-        'connectionStatus': 'online',
-        'activeTime': 1,
-        'power': 'None',
-        'voltage': 'None',
-    },
-    200,
-)
-
-DETAILS_10A = (
-    {
-        'code': 0,
-        'msg': None,
-        'deviceStatus': 'on',
-        'connectionStatus': 'online',
-        'activeTime': 1,
-        'energy': 1,
-        'nightLightStatus': None,
-        'nightLightBrightness': None,
-        'nightLightAutomode': None,
-        'power': '1',
-        'voltage': '1',
-    },
-    200,
-)
-
-DETAILS_OUTDOOR = (
-    {
-        'code': 0,
-        'msg': None,
-        'connectionStatus': 'online',
-        'activeTime': 1,
-        'energy': 1,
-        'power': '1',
-        'voltage': '1',
-        'deviceStatus': 'on',
-        'deviceName': 'Etekcity Outdoor Plug',
-        'subDevices': [
-            {
-                'subDeviceNo': 1,
-                'defaultName': 'Socket A',
-                'subDeviceName': 'Outdoor Socket A',
-                'subDeviceStatus': 'on',
-            },
-            {
-                'subDeviceNo': 2,
-                'defaultName': 'Socket B',
-                'subDeviceName': 'Outdoor Socket B',
-                'subDeviceStatus': 'on',
-            },
-        ],
-    },
-    200,
-)
-
-DETAILS_ESL100 = (
-    {
-        'code': 0,
-        'msg': None,
-        'deviceStatus': 'on',
-        'connectionStatus': 'online',
-        'name': 'Etekcity Soft White Bulb',
-        'brightNess': '1',
-        'timer': None,
-        'away': None,
-        'schedule': None,
-        'ownerShip': '1',
-        'scheduleCount': 0,
-    },
-    200,
-)
-
-DETAILS_ESL100CW = (
-    {
-        "code": 0,
-        "msg": None,
-        "deviceStatus": "on",
-        "connectionStatus": "online",
-        "deviceImg": "https://smartapi.vesync.com/v1/app/imgs/icon_dimmable_bulb/icon_dimmable_bulb_160.png",
-        "name": "Etekcity Soft White Bulb",
-        "brightNess": "100",
-    },
-    200
-)
-
-DETAILS_ESL100MC = (
-    {
-        "action": "on",
-        "brightness": 100,
-        "colorMode": "color",
-        "speed": 0,
-        "red": 255,
-        "green": 1,
-        "blue": 0
-    }, 200
-)
-
-DETAILS_VALCENO = (
-    {
-        "traceId": TRACE_ID,
-        "code": 0,
-        "msg": "request success",
-        "result": {
-            "traceId": TRACE_ID,
-            "code": 0,
-            "result": {
-                "enabled": "off",
-                "colorMode": "hsv",
-                "brightness": 80,
-                "colorTemp": 100,
-                "hue": 10000,
-                "saturation": 10000,
-                "value": 10
-            }
-        }
-    }, 200
-)
-
-DETAILS_AIR = (
-    {
-        'code': 0,
-        'msg': None,
-        'deviceStatus': 'on',
-        'connectionStatus': 'online',
-        'activeTime': 1,
-        'deviceImg': None,
-        'deviceName': 'XXXXXXX',
-        'filterLife': {'change': False, 'useHour': None, 'percent': 100},
-        'airQuality': 'excellent',
-        'screenStatus': 'on',
-        'mode': 'manual',
-        'level': 1,
-        'schedule': None,
-        'timer': None,
-        'scheduleCount': 0,
-    },
-    200,
-)
-
 DETAILS_BADCODE = (
     {
         'code': 1,
@@ -703,8 +631,8 @@ def get_details_body():
 )
 
 STATUS_BODY = {
-    'accountID': SAMPLE_ACTID,
-    'token': SAMPLE_TOKEN,
+    'accountID': Defaults.account_id,
+    'token': Defaults.token,
     'uuid': 'UUID',
     'timeZone': DEFAULT_TZ,
 }
@@ -716,7 +644,7 @@ def off_body():
     return body, 200
 
 
-def on_body(cls):
+def on_body():
     body = STATUS_BODY
     body['status'] = 'on'
     return body, 200
diff --git a/src/tests/call_json_bulbs.py b/src/tests/call_json_bulbs.py
new file mode 100644
index 0000000..b8ab5ce
--- /dev/null
+++ b/src/tests/call_json_bulbs.py
@@ -0,0 +1,183 @@
+"""
+Light Bulbs Device API Responses
+
+OUTLET variable is a list of device types
+
+DETAILS_RESPONSES variable is a dictionary of responses from the API
+for get_details() methods.  The keys are the device types and the
+values are the responses.  The responses are tuples of (response, status)
+
+METHOD_RESPONSES variable is a defaultdict of responses from the API. This is
+the FunctionResponse variable from the utils module in the tests dir.
+The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200).
+
+The values of METHOD_RESPONSES can be a function that takes a single argument or
+a static value. The value is checked if callable at runtime and if so, it is called
+with the provided argument. If not callable, the value is returned as is.
+
+METHOD_RESPONSES = {
+    'ESL100': defaultdict(
+        lambda: ({"code": 0, "msg": "success"}, 200))
+    )
+}
+
+# For a function to handle the response
+def status_response(request_body=None):
+    # do work with request_body
+    return request_body, 200
+
+METHOD_RESPONSES['ESL100']['set_status'] = status_response
+
+# To change the default value for a device type
+
+METHOD_RESPONSES['XYD0001'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200)
+
+"""
+from copy import deepcopy
+from utils import FunctionResponses, Defaults
+from pyvesync.vesyncbulb import bulb_modules
+TRACE_ID = "TRACE_ID"
+
+# BULBS = ['ESL100', 'ESL100CW', 'ESL100MC', 'XYD0001']
+BULBS = bulb_modules.keys()
+BULBS_NUM = len(BULBS)
+
+
+class BulbDetails:
+    details_esl100 = ({
+        'code': 0,
+        'msg': None,
+        'deviceStatus': 'on',
+        'connectionStatus': 'online',
+        'name': Defaults.name('ESL100'),
+        'brightNess': Defaults.brightness,
+        'timer': None,
+        'away': None,
+        'schedule': None,
+        'ownerShip': '1',
+        'scheduleCount': 0,
+    }, 200)
+
+    details_esl100cw = ({
+        "traceId": TRACE_ID,
+        "code": 0,
+        "msg": None,
+        "module": None,
+        "stacktrace": None,
+        "result": {
+            "light": {
+                "action": "on",
+                "brightness": Defaults.brightness,
+                "colorTempe": Defaults.color_temp,
+            }
+        }
+    }, 200)
+
+    details_esl100mc = ({
+        "traceId": Defaults.trace_id,
+        "code": 0,
+        "msg": "request success",
+        "result": {
+            "traceId": Defaults.trace_id,
+            "code": 0,
+            "result": {
+                "action": "on",
+                "brightness": Defaults.brightness,
+                "colorMode": "color",
+                "speed": 0,
+                "red": Defaults.color.rgb.red,
+                "green": Defaults.color.rgb.green,
+                "blue": Defaults.color.rgb.blue,
+            }
+        }
+    }, 200)
+
+    details_valceno = (
+            {
+                "traceId": TRACE_ID,
+                "code": 0,
+                "msg": "request success",
+                "result": {
+                    "traceId": TRACE_ID,
+                    "code": 0,
+                    "result": {
+                        "enabled": "on",
+                        "colorMode": "color",
+                        "brightness": Defaults.brightness,
+                        "colorTemp": Defaults.color_temp,
+                        "hue": Defaults.color.hsv.hue*27.7778,
+                        "saturation": Defaults.color.hsv.saturation*100,
+                        "value": Defaults.color.hsv.value,
+                    }
+                }
+            }, 200
+        )
+
+
+DETAILS_RESPONSES = {
+    'ESL100': BulbDetails.details_esl100,
+    'ESL100CW': BulbDetails.details_esl100cw,
+    'ESL100MC': BulbDetails.details_esl100mc,
+    'XYD0001': BulbDetails.details_valceno,
+}
+
+
+def valceno_set_status_response(kwargs=None):
+    default_resp = {
+        "traceId": Defaults.trace_id,
+        "code": 0,
+        "msg": "request success",
+        "result": {
+            "traceId": Defaults.trace_id,
+            "code": 0,
+            "result": {
+                "enabled": "on",
+                "colorMode": "hsv",
+                "brightness": Defaults.brightness,
+                "colorTemp": Defaults.color_temp,
+                "hue": Defaults.color.hsv.hue*27.7778,
+                "saturation": Defaults.color.hsv.saturation*100,
+                "value": Defaults.color.hsv.value
+            }
+        }
+    }
+    if kwargs is not None and isinstance(kwargs, dict):
+        if kwargs.get('hue') is not None:
+            default_resp['result']['result']['hue'] = kwargs['hue'] * 27.7778
+        if kwargs.get('saturation') is not None:
+            default_resp['result']['result']['saturation'] = kwargs['saturation'] * 100
+        if kwargs.get('value') is not None:
+            default_resp['result']['result']['value'] = kwargs['value']
+    return default_resp, 200
+
+
+METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in BULBS}
+
+METHOD_RESPONSES['XYD0001'].default_factory = lambda: ({
+                                                "traceId": Defaults.trace_id,
+                                                "code": 0,
+                                                "msg": "request success",
+                                                "result": {
+                                                    "traceId": Defaults.trace_id,
+                                                    "code": 0
+                                                }
+                                            }, 200)
+
+METHOD_RESPONSES['ESL100MC'].default_factory = lambda: ({
+                                                "traceId": Defaults.trace_id,
+                                                "code": 0,
+                                                "msg": "request success",
+                                                "result": {
+                                                    "traceId": Defaults.trace_id,
+                                                    "code": 0
+                                                }
+                                            }, 200)
+
+XYD0001_RESP = {
+    'set_brightness': valceno_set_status_response,
+    'set_color_temp': valceno_set_status_response,
+    'set_hsv': valceno_set_status_response,
+    'set_rgb': valceno_set_status_response,
+}
+
+METHOD_RESPONSES['XYD0001'].update(XYD0001_RESP)
diff --git a/src/tests/call_json_fans.py b/src/tests/call_json_fans.py
new file mode 100644
index 0000000..7d3da39
--- /dev/null
+++ b/src/tests/call_json_fans.py
@@ -0,0 +1,206 @@
+"""
+Air Purifier and Humidifier Device API Responses
+
+FANS variable is a list of device types
+
+DETAILS_RESPONSES variable is a dictionary of responses from the API
+for get_details() methods.  The keys are the device types and the
+values are the responses.  The responses are tuples of (response, status)
+
+METHOD_RESPONSES variable is a defaultdict of responses from the API. This is
+the FunctionResponse variable from the utils module in the tests dir.
+The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200).
+
+The values of METHOD_RESPONSES can be a function that takes a single argument or
+a static value. The value is checked if callable at runtime and if so, it is called
+with the provided argument. If not callable, the value is returned as is.
+
+METHOD_RESPONSES = {
+    'DEV_TYPE': defaultdict(
+        lambda: ({"code": 0, "msg": "success"}, 200))
+    )
+}
+
+# For a function to handle the response
+def status_response(request_body=None):
+    # do work with request_body
+    return request_body, 200
+
+METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response
+
+# To change the default value for a device type
+
+METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200)
+"""
+from copy import deepcopy
+from pyvesync import vesyncfan
+from utils import Defaults, FunctionResponses
+
+HUMID_MODELS = []
+for model_type, model_dict in vesyncfan.humid_features.items():
+    HUMID_MODELS.append(model_dict['models'][0])
+
+AIR_MODELS = []
+for model_type, model_dict in vesyncfan.air_features.items():
+    AIR_MODELS.append(model_dict['models'][0])
+
+FANS = HUMID_MODELS + AIR_MODELS
+FANS_NUM = len(FANS)
+# FANS = ['Core200S', 'Core300S', 'Core400S', 'Core600S', 'LV-PUR131S', 'LV600S',
+#         'Classic300S', 'Classic200S', 'Dual200S', 'LV600S']
+
+
+class FanDefaults:
+    fan_level = 1
+    filter_life = 80
+    humidity = 50
+    mist_level = 3
+    warm_mist_level = 2
+    air_quality = 3
+    air_quality_value = 4
+
+
+class FanDetails:
+    details_air = (
+        {
+            'code': 0,
+            'msg': None,
+            'deviceStatus': 'on',
+            'connectionStatus': 'online',
+            'activeTime': Defaults.active_time,
+            'deviceImg': None,
+            'deviceName': 'LV-PUR131S-NAME',
+            'filterLife': {'change': False, 'useHour': None, 'percent': 100},
+            'airQuality': 'excellent',
+            'screenStatus': 'on',
+            'mode': 'manual',
+            'level': FanDefaults.fan_level,
+            'schedule': None,
+            'timer': None,
+            'scheduleCount': 0,
+        },
+        200,
+    )
+
+    details_lv600s = (
+        {
+            "traceId": Defaults.trace_id,
+            "code": 0,
+            "msg": "请求成功",
+            "result": {
+                "traceId": Defaults.trace_id,
+                "code": 0,
+                "result": {
+                    "enabled": True,
+                    "humidity": FanDefaults.humidity,
+                    "mist_virtual_level": 8,
+                    "mist_level": FanDefaults.mist_level,
+                    "mode": "manual",
+                    "water_lacks": False,
+                    "humidity_high": False,
+                    "water_tank_lifted": False,
+                    "display": False,
+                    "automatic_stop_reach_target": True,
+                    "night_light_brightness": 0,
+                    "warm_mist_level": 0,
+                    "warm_mist_enabled": False,
+                    "configuration": {
+                        "auto_target_humidity": 50,
+                        "display": False,
+                        "automatic_stop": True
+                    }
+                }
+            }
+        }, 200
+    )
+
+    details_classic200s300s = (
+        {
+            "traceId": Defaults.trace_id,
+            "code": 0,
+            "msg": "请求成功",
+            "result": {
+                "traceId": Defaults.trace_id,
+                "code": 0,
+                "result": {
+                    "enabled": True,
+                    "humidity": FanDefaults.humidity,
+                    "mist_virtual_level": 8,
+                    "mist_level": FanDefaults.mist_level,
+                    "mode": "manual",
+                    "water_lacks": False,
+                    "humidity_high": False,
+                    "water_tank_lifted": False,
+                    "display": False,
+                    "automatic_stop_reach_target": True,
+                    "night_light_brightness": 0,
+                    "configuration": {
+                        "auto_target_humidity": 50,
+                        "display": False,
+                        "automatic_stop": True
+                    }
+                }
+            }
+        }, 200
+    )
+
+    details_core = (
+        {
+            "traceId": Defaults.trace_id,
+            "code": 0,
+            "msg": "request success",
+            "result": {
+                "traceId": Defaults.trace_id,
+                "code": 0,
+                "result": {
+                    "enabled": True,
+                    "filter_life": 3,
+                    "mode": "manual",
+                    "level": FanDefaults.fan_level,
+                    "air_quality": FanDefaults.air_quality,
+                    "air_quality_value": FanDefaults.air_quality_value,
+                    "display": True,
+                    "child_lock": True,
+                    "configuration": {
+                        "display": True,
+                        "display_forever": True,
+                        "auto_preference": {
+                            "type": "default",
+                            "room_size": 0
+                        }
+                    },
+                    "extension": {
+                        "schedule_count": 0,
+                        "timer_remain": 0
+                    },
+                    "device_error_code": 0
+                }
+            }
+        }, 200
+    )
+
+
+DETAILS_RESPONSES = {
+    'LV-PUR131S': FanDetails.details_air,
+    'Classic300S': FanDetails.details_classic200s300s,
+    'Classic200S': FanDetails.details_classic200s300s,
+    'Dual200S': FanDetails.details_classic200s300s,
+    'LUH-A602S-WUSR': FanDetails.details_lv600s,
+    'Core200S': FanDetails.details_core,
+    'Core300S': FanDetails.details_core,
+    'Core400S': FanDetails.details_core,
+    'Core600S': FanDetails.details_core,
+    'LUH-O451S-WUS': FanDetails.details_lv600s,
+}
+
+FunctionResponses.default_factory = lambda: ({
+                                                "traceId": Defaults.trace_id,
+                                                "code": 0,
+                                                "msg": "request success",
+                                                "result": {
+                                                    "traceId": Defaults.trace_id,
+                                                    "code": 0
+                                                }
+                                            }, 200)
+
+METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in FANS}
diff --git a/src/tests/call_json_outlets.py b/src/tests/call_json_outlets.py
new file mode 100644
index 0000000..b60cf2f
--- /dev/null
+++ b/src/tests/call_json_outlets.py
@@ -0,0 +1,163 @@
+"""
+Outlet Device API Responses
+
+OUTLET variable is a list of device types
+
+DETAILS_RESPONSES variable is a dictionary of responses from the API
+for get_details() methods.  The keys are the device types and the
+values are the responses.  The responses are tuples of (response, status)
+
+METHOD_RESPONSES variable is a defaultdict of responses from the API. This is
+the FunctionResponse variable from the utils module in the tests dir.
+The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200).
+
+The values of METHOD_RESPONSES can be a function that takes a single argument or
+a static value. The value is checked if callable at runtime and if so, it is called
+with the provided argument. If not callable, the value is returned as is.
+
+METHOD_RESPONSES = {
+    'DEV_TYPE': defaultdict(
+        lambda: ({"code": 0, "msg": "success"}, 200))
+    )
+}
+
+### For a function to handle the response
+def status_response(request_kwargs=None):
+    # do work with request_kwargs
+    return request_body, 200
+
+METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response
+
+### To change the default value for a device type
+
+METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200)
+
+If changing default response for all devices, change the default factory of the import default dict
+but make sure to use `deepcopy` to avoid unintended side effects.
+"""
+from copy import deepcopy
+from utils import FunctionResponses, Defaults
+from pyvesync import vesyncoutlet
+
+OUTLETS = vesyncoutlet.outlet_modules.keys()
+OUTLETS_NUM = len(OUTLETS)
+
+# OUTLETS = ['wifi-switch-1.3', 'ESW03-USA', 'ESW01-EU', 'ESW15-USA', 'ESO15-TB']
+
+
+class OutletDefaults:
+    voltage = 1  # volts
+    energy = 1  # kilowatt
+    power = 1  # kilowatt-hours
+    round_7a_voltage = '1000:1000'  # 1 volt
+    round_7a_power = '1000:1000'  # 1 watt
+
+
+
+class OutletDetails:
+    details_15a = (
+        {
+            'code': 0,
+            'msg': None,
+            'deviceStatus': 'on',
+            'connectionStatus': 'online',
+            'activeTime': Defaults.active_time,
+            'energy': 1,
+            'nightLightStatus': 'on',
+            'nightLightBrightness': 50,
+            'nightLightAutomode': 'manual',
+            'power': '1',
+            'voltage': '1',
+        },
+        200,
+    )
+
+    details_7a = (
+        {
+            'deviceStatus': 'on',
+            'deviceImg': '',
+            'activeTime': Defaults.active_time,
+            'energy': OutletDefaults.energy,
+            'power': OutletDefaults.round_7a_power,
+            'voltage': OutletDefaults.round_7a_voltage,
+        },
+        200,
+    )
+
+    details_10a = (
+        {
+            'code': 0,
+            'msg': None,
+            'deviceStatus': 'on',
+            'connectionStatus': 'online',
+            'activeTime': Defaults.active_time,
+            'energy': OutletDefaults.energy,
+            'nightLightStatus': None,
+            'nightLightBrightness': None,
+            'nightLightAutomode': None,
+            'power': OutletDefaults.power,
+            'voltage': OutletDefaults.voltage,
+        },
+        200,
+    )
+
+    details_outdoor = (
+        {
+            'code': 0,
+            'msg': None,
+            'connectionStatus': 'online',
+            'activeTime': Defaults.active_time,
+            'energy': OutletDefaults.energy,
+            'power': OutletDefaults.power,
+            'voltage': OutletDefaults.voltage,
+            'deviceStatus': 'on',
+            'deviceName': Defaults.name('ESO15-TB'),
+            'subDevices': [
+                {
+                    'subDeviceNo': 1,
+                    'defaultName': 'Socket A',
+                    'subDeviceName': Defaults.name('ESO15-TB'),
+                    'subDeviceStatus': 'on',
+                },
+                {
+                    'subDeviceNo': 2,
+                    'defaultName': 'Socket B',
+                    'subDeviceName': Defaults.name('ESO15-TB'),
+                    'subDeviceStatus': 'on',
+                },
+            ],
+        },
+        200,
+    )
+
+
+DETAILS_RESPONSES = {
+    'wifi-switch-1.3': OutletDetails.details_7a,
+    'ESW03-USA': OutletDetails.details_10a,
+    'ESW01-EU': OutletDetails.details_10a,
+    'ESW15-USA': OutletDetails.details_15a,
+    'ESO15-TB': OutletDetails.details_outdoor,
+}
+
+ENERGY_HISTORY = (
+    {
+        'code': 0,
+        'energyConsumptionOfToday': 1,
+        'costPerKWH': 1,
+        'maxEnergy': 1,
+        'totalEnergy': 1,
+        'data': [
+            1,
+            1,
+        ],
+    },
+    200,
+)
+
+
+METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in OUTLETS}
+
+for k in METHOD_RESPONSES:
+    METHOD_RESPONSES[k]['get_weekly_energy'] = ENERGY_HISTORY
+    METHOD_RESPONSES[k]['get_monthly_energy'] = ENERGY_HISTORY
+    METHOD_RESPONSES[k]['get_yearly_energy'] = ENERGY_HISTORY
diff --git a/src/tests/call_json_switches.py b/src/tests/call_json_switches.py
new file mode 100644
index 0000000..911bc9b
--- /dev/null
+++ b/src/tests/call_json_switches.py
@@ -0,0 +1,62 @@
+"""
+Switch Device API Responses
+
+SWITCHES variable is a list of device types
+
+DETAILS_RESPONSES variable is a dictionary of responses from the API
+for get_details() methods.  The keys are the device types and the
+values are the responses.  The responses are tuples of (response, status)
+
+METHOD_RESPONSES variable is a defaultdict of responses from the API. This is
+the FunctionResponse variable from the utils module in the tests dir.
+The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200).
+
+"""
+from copy import deepcopy
+from pyvesync import vesyncswitch
+from utils import FunctionResponses, Defaults
+
+SWITCHES = vesyncswitch.switch_modules.keys()
+SWITCHES_NUM = len(SWITCHES)
+
+
+class SwitchDetails:
+    details_ws = (
+        {
+            'code': 0,
+            'msg': None,
+            'deviceStatus': 'on',
+            'connectionStatus': 'online',
+            'activeTime': Defaults.active_time,
+            'power': 'None',
+            'voltage': 'None',
+        },
+        200,
+    )
+    details_eswd16 = ({
+                          "code": 0,
+                          "msg": "请求成功",
+                          "traceId": Defaults.trace_id,
+                          "indicatorlightStatus": "on",
+                          "timer": None,
+                          "schedule": None,
+                          "brightness": "100",
+                          "startMode": None,
+                          "activeTime": Defaults.active_time,
+                          "rgbStatus": "on",
+                          "rgbValue": {
+                              "red": Defaults.color.rgb.red,
+                              "blue": Defaults.color.rgb.blue,
+                              "green": Defaults.color.rgb.green
+                          },
+                          "connectionStatus": "online",
+                          "devicename": Defaults.name('ESWD16'),
+                          "deviceStatus": "on"
+                      }, 200)
+DETAILS_RESPONSES = {
+    'ESWL01': SwitchDetails.details_ws,
+    'ESWD16': SwitchDetails.details_eswd16,
+    'ESWL03': SwitchDetails.details_ws,
+}
+
+METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in SWITCHES}
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
new file mode 100644
index 0000000..91250a5
--- /dev/null
+++ b/src/tests/conftest.py
@@ -0,0 +1,47 @@
+def pytest_generate_tests(metafunc):
+    if metafunc.cls is None or 'test_x' in metafunc.module.__name__:
+        return
+
+    if 'device' in metafunc.cls.__dict__:
+        device = metafunc.cls.__dict__['device']
+        if device not in metafunc.cls.__dict__:
+            return
+        devices = metafunc.cls.__dict__[device]
+        if metafunc.function.__name__ == 'test_details':
+            return details_generator(metafunc, device, devices)
+
+        elif metafunc.function.__name__ == 'test_methods':
+            return method_generator(metafunc, device, devices)
+
+
+def details_generator(metafunc, gen_type, devices):
+    """Parametrize device tests for get_details()."""
+    id_list = []
+    argvalues = []
+    for dev_type in devices:
+        id_list.append(f"{gen_type}.{dev_type}.update")
+        argvalues.append([dev_type, 'update'])
+    metafunc.parametrize("dev_type, method", argvalues, ids=id_list)
+    return
+
+
+def method_generator(metafunc, gen_type, devices):
+    """Parametrize device tests for methods."""
+    if 'base_methods' in metafunc.cls.__dict__:
+        base_methods = metafunc.cls.__dict__['base_methods']
+    else:
+        base_methods = []
+    call_dict = {dt: base_methods.copy() for dt in devices}
+    if 'device_methods' in metafunc.cls.__dict__:
+        dev_methods = metafunc.cls.__dict__['device_methods']
+        for dev in call_dict:
+            if dev in dev_methods:
+                call_dict[dev].extend(dev_methods[dev])
+    id_list = []
+    argvalues = []
+    for dev, methods in call_dict.items():
+        for method in methods:
+            id_list.append(f'{gen_type}.{dev}.{method}')
+            argvalues.append([dev, method])
+    metafunc.parametrize("dev_type, method", argvalues, ids=id_list)
+    return
\ No newline at end of file
diff --git a/src/tests/request_api.yaml b/src/tests/request_api.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/test_all_devices.py b/src/tests/test_all_devices.py
new file mode 100644
index 0000000..742421d
--- /dev/null
+++ b/src/tests/test_all_devices.py
@@ -0,0 +1,87 @@
+"""
+This tests all requests made by the pyvesync library with pytest.
+
+All tests inherit from the TestBase class which contains the fixtures
+and methods needed to run the tests.
+
+The `helpers.call_api` method is patched to return a mock response.
+The method, endpoint, headers and json arguments are recorded
+in YAML files in the api directory, catagorized in folders by
+module and files by the class name.
+
+The default is to record requests that do not exist and compare requests
+that already exist. If the API changes, set the overwrite argument to True
+in order to overwrite the existing YAML file with the new request.
+"""
+import logging
+import call_json
+import call_json_outlets
+import call_json_bulbs
+import call_json_fans
+import call_json_switches
+from utils import TestBase, assert_test, parse_args
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+LOGIN_RESPONSE = call_json.LOGIN_RET_BODY
+
+
+def test_device_tests():
+    """Test to ensure all devices have are defined for testing.
+
+    All devices should have an entry in the DETAILS_RESPONSES dict
+    with response for get_details() method. This test ensures that
+    all devices have been configured for testing. The details response
+    should be located in `{device_type}Details` class of the respective
+    call_json_{device_type} module and the DETAILS_RESPONSE module variable.
+    The class variable with the details response does not matter, the dictionary
+    key of DETAILS_RESPONSES should match the device type.
+
+    Examples
+    ---------
+    class FanDetails:
+        "Core200SResponse": {'speed': 1, 'device_status': 'on'}
+
+    DETAILS_RESPONSES = {
+        'Core200S': FanDetails.Core200SResponse
+    }
+
+    Asserts
+    -------
+    Number of devices for each type has a response defined in the
+        respective `call_json` module.
+
+    See Also
+    --------
+    src/tests/README.md - README located in the tests directory
+    """
+    assert call_json_fans.FANS_NUM == len(call_json_fans.DETAILS_RESPONSES)
+    assert call_json_bulbs.BULBS_NUM == len(call_json_bulbs.DETAILS_RESPONSES)
+    assert call_json_outlets.OUTLETS_NUM == len(call_json_outlets.DETAILS_RESPONSES)
+    assert call_json_switches.SWITCHES_NUM == len(call_json_switches.DETAILS_RESPONSES)
+
+
+class TestGeneralAPI(TestBase):
+    """General API testing class for login() and get_devices()."""
+
+    def test_login(self):
+        """Test login() method request and API response."""
+        print("Test Login")
+        self.mock_api.return_value = LOGIN_RESPONSE
+        self.manager.enabled = False
+        assert self.manager.login()
+        all_kwargs = parse_args(self.mock_api)
+        assert assert_test(self.manager.login, all_kwargs)
+
+    def test_get_devices(self):
+        """Test get_devices() method request and API response."""
+        print("Test Device List")
+        self.mock_api.return_value = call_json.DeviceList.device_list_response()
+        self.manager.get_devices()
+        all_kwargs = parse_args(self.mock_api)
+        assert assert_test(self.manager.get_devices, all_kwargs)
+        assert len(self.manager.bulbs) == call_json_bulbs.BULBS_NUM
+        assert len(self.manager.outlets) == call_json_outlets.OUTLETS_NUM
+        assert len(self.manager.fans) == call_json_fans.FANS_NUM
+        assert len(self.manager.switches) == call_json_switches.SWITCHES_NUM
diff --git a/src/tests/test_bulbs.py b/src/tests/test_bulbs.py
new file mode 100644
index 0000000..04c134f
--- /dev/null
+++ b/src/tests/test_bulbs.py
@@ -0,0 +1,256 @@
+"""
+This tests requests made by bulb devices.
+
+All tests inherit from the TestBase class which contains the fixtures
+and methods needed to run the tests.
+
+The tests are automatically parametrized by `pytest_generate_tests` in
+conftest.py. The two methods that are parametrized are `test_details`
+and `test_methods`. The class variables are used to build the list of
+devices, test methods and arguments.
+
+The `helpers.call_api` method is patched to return a mock response.
+The method, endpoint, headers and json arguments are recorded
+in YAML files in the api directory, catagorized in folders by
+module and files by the class name.
+
+The default is to record requests that do not exist and compare requests
+that already exist. If the API changes, set the overwrite argument to True
+in order to overwrite the existing YAML file with the new request.
+
+See Also
+--------
+`utils.TestBase` - Base class for all tests, containing mock objects
+`confest.pytest_generate_tests` - Parametrizes tests based on
+    method names & class attributes
+`call_json_bulbs` - Contains API responses
+"""
+
+import logging
+import math
+from pyvesync.vesync import object_factory
+from pyvesync.vesyncbulb import pct_to_kelvin
+from utils import TestBase, assert_test, parse_args, Defaults
+import call_json
+import call_json_bulbs
+
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+DEFAULT_COLOR = Defaults.color
+DEFAULT_COLOR_RGB = dict(DEFAULT_COLOR.rgb._asdict())
+DEFAULT_COLOR_HSV = dict(DEFAULT_COLOR.hsv._asdict())
+RGB_SET = {
+    'red': 50,
+    'green': 200,
+    'blue': 255,
+
+}
+HSV_SET = {
+    'hue': 200,
+    'saturation': 50,
+    'value': 100,
+}
+
+
+class TestBulbs(TestBase):
+    """Bulbs testing class.
+
+    This class tests bulb device details and methods. The methods are
+    parametrized from the class variables using `pytest_generate_tests`.
+    The call_json_bulbs module contains the responses for the API requests.
+    The device is instantiated from the details provided by
+    `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`.
+
+    Instance Attributes
+    -------------------
+    self.manager : VeSync
+        Instantiated VeSync object
+    self.mock_api : Mock
+        Mock with patched `helpers.call_api` method
+    self.caplog : LogCaptureFixture
+        Pytest fixture for capturing logs
+
+    Class Attributes
+    -----------------
+    device : str
+        Name of device type - bulbs
+    bulbs : list
+        List of device types for bulbs, this variable is named
+        after the device class attribute value
+    base_methods : List[List[str, Dict[str, Any]]]
+        List of common methods for all devices
+    device_methods : Dict[List[List[str, Dict[str, Any]]]]
+        Dictionary of methods specific to device types
+
+    Methods
+    --------
+    test_details()
+        Test the device details API request and response
+    test_methods()
+        Test device methods API request and response
+
+    Examples
+    --------
+    >>> device = 'bulbs'
+    >>> bulbs = call_json_bulbs.bulbs
+    >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+    >>> device_methods = {
+        'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]]
+        }
+
+    """
+
+    device = 'bulbs'
+    bulbs = call_json_bulbs.BULBS
+    base_methods = [['turn_on'], ['turn_off'], ['set_brightness', {'brightness': 50}]]
+    device_methods = {
+        'ESL100CW': [['set_color_temp', {'color_temp': 50}]],
+        'ESL100MC': [['set_rgb', RGB_SET],
+                     ['enable_white_mode']],
+        'XYD0001': [['set_hsv', HSV_SET],
+                    ['set_color_temp', {'color_temp': 50}],
+                    ['enable_white_mode']
+                    ]
+    }
+
+    def test_details(self, dev_type, method):
+        """Test the device details API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class attribute `device` (name of device type - bulbs),
+        device name (bulbs) list of device types.
+
+        Example:
+            >>> device = 'bulbs'
+            >>> bulbs = call_json_bulbs.BULBS
+
+        See Also
+        --------
+        `utils.TestBase` class docstring
+        `call_json_bulbs` module docstring
+
+        Notes
+        ------
+        The device is instantiated using the `call_json.DeviceList.device_list_item()`
+        method. The device details contain the default values set in `utils.Defaults`
+        """
+        # Set return value for call_api based on call_json_bulb.DETAILS_RESPONSES
+        return_val = call_json_bulbs.DETAILS_RESPONSES[dev_type]
+        self.mock_api.return_value = return_val
+
+        # Instantiate device from device list return item
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+        _, bulb_obj = object_factory(dev_type,
+                                     device_config,
+                                     self.manager)
+        method_call = getattr(bulb_obj, method)
+        method_call()
+
+        # Parse mock_api args tuple from arg, kwargs to kwargs
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+        # Assert device details match expected values
+        assert bulb_obj.brightness == Defaults.brightness
+        if bulb_obj.rgb_shift_feature:
+            assert self._assert_color(bulb_obj)
+        if bulb_obj.color_temp_feature:
+            assert bulb_obj.color_temp_pct == Defaults.color_temp
+            assert bulb_obj.color_temp_kelvin == pct_to_kelvin(Defaults.color_temp)
+
+    def test_methods(self, dev_type, method):
+        """Test device methods API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - bulbs),
+        device name (bulbs) list of device types, `base_methods` - list of
+        methods for all devices, and `device_methods` - list of methods for
+        each device type.
+
+        Example:
+            >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+            >>> device_methods = {
+                'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]]
+                }
+
+        Notes
+        -----
+        The response can be a callable that accepts the `kwargs` argument to
+        sync the device response with the API response. In some cases the API
+        returns data from the method call, such as `get_yearly_energy`, in other cases the
+        API returns a simple confirmation the command was successful.
+
+        See Also
+        --------
+        `TestBase` class method
+        `call_json_bulbs` module
+
+        """
+        # Get method name and kwargs from method fixture
+        method_name = method[0]
+        if len(method) == 2 and isinstance(method[1], dict):
+            method_kwargs = method[1]
+        else:
+            method_kwargs = {}
+
+        # Set return value for call_api based on call_json_bulbs.METHOD_RESPONSES
+        method_response = call_json_bulbs.METHOD_RESPONSES[dev_type][method_name]
+        if callable(method_response):
+            if method_kwargs:
+                self.mock_api.return_value = method_response(method_kwargs)
+            else:
+                self.mock_api.return_value = method_response()
+        else:
+            self.mock_api.return_value = method_response
+
+        # Get device configuration from call_json.DeviceList.device_list_item()
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+
+        # Instantiate device from device list return item
+        _, bulb_obj = object_factory(dev_type,
+                                     device_config,
+                                     self.manager)
+
+        # Get method from device object
+        method_call = getattr(bulb_obj, method[0])
+
+        # Ensure method runs based on device configuration
+        if method[0] == 'turn_on':
+            bulb_obj.device_status = 'off'
+        elif method[0] == 'turn_off':
+            bulb_obj.device_status = 'on'
+
+        # Call method with kwargs if defined
+        if method_kwargs:
+            method_call(**method_kwargs)
+        else:
+            method_call()
+
+        # Parse arguments from mock_api call into a dictionary
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+    def _assert_color(self, bulb_obj):
+        assert math.isclose(bulb_obj.color_rgb.red, DEFAULT_COLOR.rgb.red, rel_tol=1)
+        assert math.isclose(bulb_obj.color.rgb.red, DEFAULT_COLOR.rgb.red, rel_tol=1)
+        assert math.isclose(bulb_obj.color_rgb.green, DEFAULT_COLOR.rgb.green, rel_tol=1)
+        assert math.isclose(bulb_obj.color.rgb.green, DEFAULT_COLOR.rgb.green, rel_tol=1)
+        assert math.isclose(bulb_obj.color_rgb.blue, DEFAULT_COLOR.rgb.blue, rel_tol=1)
+        assert math.isclose(bulb_obj.color.rgb.blue, DEFAULT_COLOR.rgb.blue, rel_tol=1)
+        assert math.isclose(bulb_obj.color_hsv.hue, DEFAULT_COLOR.hsv.hue, rel_tol=1)
+        assert math.isclose(bulb_obj.color_hue, DEFAULT_COLOR.hsv.hue, rel_tol=1)
+        assert math.isclose(bulb_obj.color_hsv.saturation,
+                            DEFAULT_COLOR.hsv.saturation,
+                            rel_tol=1)
+        assert math.isclose(bulb_obj.color_saturation,
+                            DEFAULT_COLOR.hsv.saturation,
+                            rel_tol=1)
+        assert math.isclose(bulb_obj.color_hsv.value, DEFAULT_COLOR.hsv.value, rel_tol=1)
+        assert math.isclose(bulb_obj.color_value, DEFAULT_COLOR.hsv.value, rel_tol=1)
+        return True
diff --git a/src/tests/test_fans.py b/src/tests/test_fans.py
new file mode 100644
index 0000000..742fa23
--- /dev/null
+++ b/src/tests/test_fans.py
@@ -0,0 +1,401 @@
+"""
+This tests requests for Humidifiers and Air Purifiers.
+
+All tests inherit from the TestBase class which contains the fixtures
+and methods needed to run the tests.
+
+The tests are automatically parametrized by `pytest_generate_tests` in
+conftest.py. The two methods that are parametrized are `test_details`
+and `test_methods`. The class variables are used to build the list of
+devices, test methods and arguments.
+
+The `helpers.call_api` method is patched to return a mock response.
+The method, endpoint, headers and json arguments are recorded
+in YAML files in the api directory, catagorized in folders by
+module and files by the class name.
+
+The default is to record requests that do not exist and compare requests
+that already exist. If the API changes, set the overwrite argument to True
+in order to overwrite the existing YAML file with the new request.
+
+See Also
+--------
+`utils.TestBase` - Base class for all tests, containing mock objects
+`confest.pytest_generate_tests` - Parametrizes tests based on
+    method names & class attributes
+`call_json_fans` - Contains API responses
+"""
+
+import logging
+from pyvesync.vesync import object_factory
+from utils import TestBase, assert_test, parse_args, Defaults
+import call_json
+import call_json_fans
+
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+DEFAULT_COLOR = Defaults.color
+DEFAULT_COLOR_RGB = dict(DEFAULT_COLOR.rgb._asdict())
+DEFAULT_COLOR_HSV = dict(DEFAULT_COLOR.hsv._asdict())
+RGB_SET = {
+    'red': 50,
+    'green': 200,
+    'blue': 255,
+
+}
+HSV_SET = {
+    'hue': 200,
+    'saturation': 50,
+    'value': 100,
+}
+
+
+class TestAirPurifiers(TestBase):
+    """Air Purifier testing class.
+
+    This class tests Air Purifier device details and methods.
+    The methods are parametrized from the class variables using
+    `pytest_generate_tests`. The call_json_fans module contains the responses
+    for the API requests. The device is instantiated from the details provided by
+    `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`.
+
+    Instance Attributes
+    -------------------
+    self.manager : VeSync
+        Instantiated VeSync object
+    self.mock_api : Mock
+        Mock with patched `helpers.call_api` method
+    self.caplog : LogCaptureFixture
+        Pytest fixture for capturing logs
+
+    Class Attributes
+    ---------------
+    device : str
+        Name of device type - air_purifiers
+    air_purifiers : list
+        List of device types for air purifiers, this variable is named
+        after the device variable value
+    base_methods : List[List[str, Dict[str, Any]]]
+        List of common methods for all devices
+    device_methods : Dict[List[List[str, Dict[str, Any]]]]
+        Dictionary of methods specific to device types
+
+    Methods
+    --------
+    test_details()
+        Test the device details API request and response
+    test_methods()
+        Test device methods API request and response
+
+    Examples
+    --------
+    >>> device = 'air_purifiers'
+    >>> air_purifiers = call_json_fans.AIR_MODELS
+    >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+    >>> device_methods = {
+        'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]]
+        }
+    """
+
+    device = 'air_purifiers'
+    air_purifiers = call_json_fans.AIR_MODELS
+    base_methods = [['turn_on'], ['turn_off'], ['sleep_mode'], ['manual_mode'],
+                    ['change_fan_speed', {'speed': 3}]]
+    device_methods = {
+        'Core300S': [['auto_mode'], ['turn_on_display'], ['turn_off_display']],
+        'Core400S': [['auto_mode'], ['turn_on_display'], ['turn_off_display']],
+        'Core600S': [['auto_mode'], ['turn_on_display'], ['turn_off_display']],
+
+    }
+
+    def test_details(self, dev_type, method):
+        """Test the device details API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - air_purifiers),
+        device name (air_purifiers) list of device types.
+
+        Example:
+            >>> device = 'air_purifiers'
+            >>> air_purifiers = call_json_fans.AIR_MODELS
+
+        See Also
+        --------
+        `utils.TestBase` class docstring
+        `call_json_fans` module docstring
+
+        Notes
+        ------
+        The device is instantiated using the `call_json.DeviceList.device_list_item()`
+        method. The device details contain the default values set in `utils.Defaults`
+        """
+        # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES
+        return_val = call_json_fans.DETAILS_RESPONSES[dev_type]
+        self.mock_api.return_value = return_val
+
+        # Instantiate device from device list return item
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+        _, fan_obj = object_factory(dev_type,
+                                    device_config,
+                                    self.manager)
+        method_call = getattr(fan_obj, method)
+        method_call()
+
+        # Parse mock_api args tuple from arg, kwargs to kwargs
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+    def test_methods(self, dev_type, method):
+        """Test device methods API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - air_purifiers),
+        device name (air_purifiers) list of device types, `base_methods` - list of
+        methods for all devices, and `device_methods` - list of methods for
+        each device type.
+
+        Example:
+            >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+            >>> device_methods = {
+                'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]]
+                }
+
+        Notes
+        -----
+        The response can be a callable that accepts the `kwargs` argument to
+        sync the device response with the API response. In some cases the API
+        returns data from the method call, such as `get_yearly_energy`, in other cases the
+        API returns a simple confirmation the command was successful.
+
+        See Also
+        --------
+        `TestBase` class method
+        `call_json_fans` module
+
+        """
+        # Get method name and kwargs from method fixture
+        method_name = method[0]
+        if len(method) == 2 and isinstance(method[1], dict):
+            method_kwargs = method[1]
+        else:
+            method_kwargs = {}
+
+        # Set return value for call_api based on call_json_fans.METHOD_RESPONSES
+        method_response = call_json_fans.METHOD_RESPONSES[dev_type][method_name]
+        if callable(method_response):
+            if method_kwargs:
+                self.mock_api.return_value = method_response(method_kwargs)
+            else:
+                self.mock_api.return_value = method_response()
+        else:
+            self.mock_api.return_value = method_response
+
+        # Get device configuration from call_json.DeviceList.device_list_item()
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+
+        # Instantiate device from device list return item
+        _, fan_obj = object_factory(dev_type,
+                                    device_config,
+                                    self.manager)
+
+        # Get method from device object
+        method_call = getattr(fan_obj, method[0])
+
+        # Ensure method runs based on device configuration
+        if method[0] == 'turn_on':
+            fan_obj.device_status = 'off'
+        elif method[0] == 'turn_off':
+            fan_obj.device_status = 'on'
+        elif method[0] == 'change_fan_speed':
+            fan_obj.mode = 'manual'
+            fan_obj.details['level'] = 1
+
+        # Call method with kwargs if defined
+        if method_kwargs:
+            method_call(**method_kwargs)
+        else:
+            method_call()
+
+        # Parse arguments from mock_api call into a dictionary
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+
+class TestHumidifiers(TestBase):
+    """Humidifier testing class.
+
+    This class tests Humidifier device details and methods. The methods are
+    parametrized from the class variables using `pytest_generate_tests`.
+    The call_json_fans module contains the responses for the API requests.
+    The device is instantiated from the details provided by
+    `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`.
+
+    Instance Attributes
+    -------------------
+    self.manager : VeSync
+        Instantiated VeSync object
+    self.mock_api : Mock
+        Mock with patched `helpers.call_api` method
+    self.caplog : LogCaptureFixture
+        Pytest fixture for capturing logs
+
+    Class Variables
+    ---------------
+    device : str
+        Name of device type - humidifiers
+    humidifers : list
+        List of device types for humidifiers, this variable is named
+        after the device variable value
+    base_methods : List[List[str, Dict[str, Any]]]
+        List of common methods for all devices
+    device_methods : Dict[List[List[str, Dict[str, Any]]]]
+        Dictionary of methods specific to device types
+
+    Methods
+    --------
+    test_details()
+        Test the device details API request and response
+    test_methods()
+        Test device methods API request and response
+
+    Examples
+    --------
+    >>> device = 'humidifiers'
+    >>> humidifiers = call_json_fans.HUMID_MODELS
+    >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+    >>> device_methods = {
+        'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]]
+        }
+
+    """
+
+    device = 'humidifiers'
+    humidifiers = call_json_fans.HUMID_MODELS
+    base_methods = [['turn_on'], ['turn_off'], ['turn_on_display'], ['turn_off_display'],
+                    ['automatic_stop_on'], ['automatic_stop_off'],
+                    ['set_humidity', {'humidity': 50}], ['set_auto_mode'],
+                    ['set_manual_mode'], ['set_mist_level', {'level': 2}]
+                    ]
+    device_methods = {
+        'LUH-A602S-WUSR': [['set_warm_level', {'warm_level': 3}]],
+
+    }
+
+    def test_details(self, dev_type, method):
+        """Test the device details API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - fans),
+        device name (fans) list of device types.
+
+        Example:
+            >>> device = 'air_purifiers'
+            >>> air_purifiers = call_json_fans.AIR_MODELS
+
+        See Also
+        --------
+        `utils.TestBase` class docstring
+        `call_json_fans` module docstring
+
+        Notes
+        ------
+        The device is instantiated using the `call_json.DeviceList.device_list_item()`
+        method. The device details contain the default values set in `utils.Defaults`
+        """
+        # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES
+        return_val = call_json_fans.DETAILS_RESPONSES[dev_type]
+        self.mock_api.return_value = return_val
+
+        # Instantiate device from device list return item
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+        _, fan_obj = object_factory(dev_type,
+                                    device_config,
+                                    self.manager)
+        method_call = getattr(fan_obj, method)
+        method_call()
+
+        # Parse mock_api args tuple from arg, kwargs to kwargs
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+    def test_methods(self, dev_type, method):
+        """Test device methods API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - humidifiers),
+        device name (humidifiers) list of device types, `base_methods` - list of
+        methods for all devices, and `device_methods` - list of methods for
+        each device type.
+
+        Example:
+            >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+            >>> device_methods = {
+                'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]]
+                }
+
+        Notes
+        -----
+        The response can be a callable that accepts the `kwargs` argument to
+        sync the device response with the API response. In some cases the API
+        returns data from the method call, such as `get_yearly_energy`, in other cases the
+        API returns a simple confirmation the command was successful.
+
+        See Also
+        --------
+        `TestBase` class method
+        `call_json_fans` module
+
+        """
+        # Get method name and kwargs from method fixture
+        method_name = method[0]
+        if len(method) == 2 and isinstance(method[1], dict):
+            method_kwargs = method[1]
+        else:
+            method_kwargs = {}
+
+        # Set return value for call_api based on call_json_fans.METHOD_RESPONSES
+        method_response = call_json_fans.METHOD_RESPONSES[dev_type][method_name]
+        if callable(method_response):
+            if method_kwargs:
+                self.mock_api.return_value = method_response(method_kwargs)
+            else:
+                self.mock_api.return_value = method_response()
+        else:
+            self.mock_api.return_value = method_response
+
+        # Get device configuration from call_json.DeviceList.device_list_item()
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+
+        # Instantiate device from device list return item
+        _, fan_obj = object_factory(dev_type,
+                                    device_config,
+                                    self.manager)
+
+        # Get method from device object
+        method_call = getattr(fan_obj, method[0])
+
+        # Ensure method runs based on device configuration
+        if method[0] == 'turn_on':
+            fan_obj.device_status = 'off'
+        elif method[0] == 'turn_off':
+            fan_obj.device_status = 'on'
+
+        # Call method with kwargs if defined
+        if method_kwargs:
+            method_call(**method_kwargs)
+        else:
+            method_call()
+
+        # Parse arguments from mock_api call into a dictionary
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
diff --git a/src/tests/test_outlets.py b/src/tests/test_outlets.py
new file mode 100644
index 0000000..2d815a4
--- /dev/null
+++ b/src/tests/test_outlets.py
@@ -0,0 +1,256 @@
+"""
+This tests all requests made by outlet devices.
+
+All tests inherit from the TestBase class which contains the fixtures
+and methods needed to run the tests.
+
+The tests are automatically parametrized by `pytest_generate_tests` in
+conftest.py. The two methods that are parametrized are `test_details`
+and `test_methods`. The class variables are used to build the list of
+devices, test methods and arguments.
+
+The `helpers.call_api` method is patched to return a mock response.
+The method, endpoint, headers and json arguments are recorded
+in YAML files in the api directory, catagorized in folders by
+module and files by the class name.
+
+The default is to record requests that do not exist and compare requests
+that already exist. If the API changes, set the overwrite argument to True
+in order to overwrite the existing YAML file with the new request.
+
+See Also
+--------
+`utils.TestBase` - Base class for all tests, containing mock objects
+`confest.pytest_generate_tests` - Parametrizes tests based on
+    method names & class attributes
+`call_json_outlets` - Contains API responses
+"""
+
+import pytest
+import logging
+from pyvesync.vesync import object_factory
+from utils import TestBase, assert_test, parse_args
+import call_json
+import call_json_outlets
+from utils import Defaults
+
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+OUTLET_DEV_TYPES = call_json_outlets.OUTLETS
+POWER_METHODS = ['get_energy_update']
+OUTLET_PARAMS = []
+[OUTLET_PARAMS.append([dev, method]) for dev in OUTLET_DEV_TYPES for method in POWER_METHODS]
+
+
+class TestOutlets(TestBase):
+    """Outlets testing class.
+
+    This class tests outlets device details and methods. The methods are
+    parametrized from the class variables using `pytest_generate_tests`.
+    The call_json_outlets module contains the responses for the API requests.
+    The device is instantiated from the details provided by
+    `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`.
+
+    Instance Attributes
+    -------------------
+    self.manager : VeSync
+        Instantiated VeSync object
+    self.mock_api : Mock
+        Mock with patched `helpers.call_api` method
+    self.caplog : LogCaptureFixture
+        Pytest fixture for capturing logs
+
+    Class Attributes
+    ---------------
+    device : str
+        Name of device type - outlets
+    outlets : list
+        List of device types for outlets, this variable is named
+        after the device variable value
+    base_methods : List[List[str, Dict[str, Any]]]
+        List of common methods for all devices
+    device_methods : Dict[List[List[str, Dict[str, Any]]]]
+        Dictionary of methods specific to device types
+
+    Methods
+    --------
+    test_details()
+        Test the device details API request and response
+    test_methods()
+        Test device methods API request and response
+
+    Examples
+    --------
+    >>> device = 'outlets'
+    >>> outlets = ['ESW01-USA', 'ESW01-EU', 'ESW01-AU']
+    >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+    >>> device_methods = {
+        'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]]
+        }
+
+    """
+
+    device = 'outlets'
+    outlets = call_json_outlets.OUTLETS
+    base_methods = [['turn_on'], ['turn_off'], ['get_yearly_energy'],
+                    ['get_monthly_energy'], ['get_weekly_energy']]
+    device_methods = {
+        'ESW15-USA': [['turn_on_nightlight'], ['turn_off_nightlight']],
+    }
+
+    def test_details(self, dev_type, method):
+        """Test the device details API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - outlets),
+        device name (outlets) list of device types.
+
+        Example:
+            >>> device = 'outlets'
+            >>> outlets = ['ESW01-USA', 'ESW01-EU', 'ESW01-AU']
+
+        See Also
+        --------
+        `utils.TestBase` class docstring
+        `call_json_outlets` module docstring
+
+        Notes
+        ------
+        The device is instantiated using the `call_json.DeviceList.device_list_item()`
+        method. The device details contain the default values set in `utils.Defaults`
+        """
+        # Set return value for call_api based on call_json_bulb.DETAILS_RESPONSES
+        self.mock_api.return_value = call_json_outlets.DETAILS_RESPONSES[dev_type]
+
+        # Instantiate device from device list return item
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+        _, outlet_obj = object_factory(dev_type,
+                                       device_config,
+                                       self.manager)
+
+        # Get and run method from object
+        method_call = getattr(outlet_obj, method)
+        method_call()
+
+        # Parse mock_api args tuple from arg, kwargs to kwargs
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+        # Assert device attributes match default values
+        assert int(outlet_obj.details['active_time']) == int(Defaults.active_time)
+        assert int(outlet_obj.details['energy']) == int(call_json_outlets.OutletDefaults.energy)
+        assert int(outlet_obj.details['power']) == int(call_json_outlets.OutletDefaults.power)
+        assert int(outlet_obj.details['voltage']) == int(call_json_outlets.OutletDefaults.voltage)
+
+    def test_methods(self, dev_type, method):
+        """Test device methods API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - outlets),
+        device name (outlets) list of device types, `base_methods` - list of
+        methods for all devices, and `device_methods` - list of methods for
+        each device type.
+
+        Example:
+            >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+            >>> device_methods = {
+                'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]]
+                }
+
+        Notes
+        -----
+        The response can be a callable that accepts the `kwargs` argument to
+        sync the device response with the API response. In some cases the API
+        returns data from the method call, such as `get_yearly_energy`, in other cases the
+        API returns a simple confirmation the command was successful.
+
+        See Also
+        --------
+        `TestBase` class method
+        `call_json_outlets` module
+
+        """
+        # Get method name and kwargs from method fixture
+        method_name = method[0]
+        if len(method) == 2 and isinstance(method[1], dict):
+            method_kwargs = method[1]
+        else:
+            method_kwargs = {}
+
+        # Set return value for call_api based on call_json_bulbs.METHOD_RESPONSES
+        method_response = call_json_outlets.METHOD_RESPONSES[dev_type][method_name]
+        if callable(method_response):
+            if method_kwargs:
+                self.mock_api.return_value = method_response(**method_kwargs)
+            else:
+                self.mock_api.return_value = method_response()
+        else:
+            self.mock_api.return_value = method_response
+
+        # Get device configuration from call_json.DeviceList.device_list_item()
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+
+        # Instantiate device from device list return item
+        _, outlet_obj = object_factory(dev_type,
+                                       device_config,
+                                       self.manager)
+
+        # Ensure method runs based on device configuration
+        if method[0] == 'turn_on':
+            outlet_obj.device_status == 'off'
+        if method[0] == 'turn_off':
+            outlet_obj.device_status == 'on'
+
+        # Get method from device object
+        method_call = getattr(outlet_obj, method[0])
+
+        # Call method with kwargs if present
+        if method_kwargs:
+            method_call(method_kwargs)
+        else:
+            method_call()
+
+        # Parse arguments from mock_api call into a dictionary
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+        # Test bad responses
+        self.mock_api.reset_mock()
+        if dev_type == 'wifi-switch-1.3':
+            self.mock_api.return_value = (None, 400)
+        else:
+            self.mock_api.return_value = call_json.DETAILS_BADCODE
+        if method[0] == 'turn_on':
+            outlet_obj.device_status == 'off'
+        if method[0] == 'turn_off':
+            outlet_obj.device_status == 'on'
+        if 'energy' in method[0]:
+            return
+        assert method_call() is False
+
+    @pytest.mark.parametrize('dev_type', OUTLET_DEV_TYPES)
+    def test_power(self, dev_type):
+        """Test outlets power history methods."""
+        self.mock_api.return_value = call_json_outlets.ENERGY_HISTORY
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+        _, outlet_obj = object_factory(dev_type, device_config, self.manager)
+        outlet_obj.update_energy()
+        assert self.mock_api.call_count == 3
+        assert list(outlet_obj.energy.keys()) == ['week', 'month', 'year']
+        self.mock_api.reset_mock()
+        outlet_obj.energy = {}
+        if dev_type == 'wifi-switch-1.3':
+            self.mock_api.return_value = (None, 400)
+        else:
+            self.mock_api.return_value = call_json.DETAILS_BADCODE
+        outlet_obj.update_energy()
+        self.mock_api.call_count == 0
+        outlet_obj.update_energy(bypass_check=True)
+        self.mock_api.assert_called_once()
+        assert 'Unable to get' in self.caplog.records[-1].message
diff --git a/src/tests/test_switches.py b/src/tests/test_switches.py
new file mode 100644
index 0000000..962e037
--- /dev/null
+++ b/src/tests/test_switches.py
@@ -0,0 +1,235 @@
+"""
+This tests requests made by switch devices.
+
+All tests inherit from the TestBase class which contains the fixtures
+and methods needed to run the tests.
+
+The tests are automatically parametrized by `pytest_generate_tests` in
+conftest.py. The two methods that are parametrized are `test_details`
+and `test_methods`. The class variables are used to build the list of
+devices, test methods and arguments.
+
+The `helpers.call_api` method is patched to return a mock response.
+The method, endpoint, headers and json arguments are recorded
+in YAML files in the api directory, catagorized in folders by
+module and files by the class name.
+
+The default is to record requests that do not exist and compare requests
+that already exist. If the API changes, set the overwrite argument to True
+in order to overwrite the existing YAML file with the new request.
+
+See Also
+--------
+`utils.TestBase` - Base class for all tests, containing mock objects
+`confest.pytest_generate_tests` - Parametrizes tests based on
+    method names & class attributes
+`call_json_switches` - Contains API responses
+"""
+
+import logging
+from pyvesync.vesync import object_factory
+from utils import TestBase, assert_test, parse_args, Defaults
+import call_json
+import call_json_switches
+
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+DEFAULT_COLOR = Defaults.color.rgb
+COLOR_DICT = {
+    'red': DEFAULT_COLOR.red,
+    'blue': DEFAULT_COLOR.blue,
+    'green': DEFAULT_COLOR.green,
+}
+
+
+class TestSwitches(TestBase):
+    """Switches testing class.
+
+    This class tests switch device details and methods. The methods are
+    parametrized from the class variables using `pytest_generate_tests`.
+    The call_json_switches module contains the responses for the API requests.
+    The device is instantiated from the details provided by
+    `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`.
+
+    Instance Attributes
+    -------------------
+    self.manager : VeSync
+        Instantiated VeSync object
+    self.mock_api : Mock
+        Mock with patched `helpers.call_api` method
+    self.caplog : LogCaptureFixture
+        Pytest fixture for capturing logs
+
+
+    Class Variables
+    ---------------
+    device : str
+        Name of device type - switches
+    switches : list
+        List of device types for switches, this variable is named
+        after the device variable value
+    base_methods : List[List[str, Dict[str, Any]]]
+        List of common methods for all devices
+    device_methods : Dict[List[List[str, Dict[str, Any]]]]
+        Dictionary of methods specific to device types
+
+    Methods
+    --------
+    test_details()
+        Test the device details API request and response
+    test_methods()
+        Test device methods API request and response
+
+    Examples
+    --------
+    >>> device = 'switches'
+    >>> switches = call_json_switches.SWITCHES
+    >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+    >>> device_methods = {
+        'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]]
+        }
+
+    """
+
+    device = 'switches'
+    switches = call_json_switches.SWITCHES
+    base_methods = [['turn_on'], ['turn_off']]
+    device_methods = {
+        'ESWD16': [['indicator_light_on'],
+                   ['rgb_color_on'],
+                   ['rgb_color_set', COLOR_DICT],
+                   ['set_brightness', {'brightness': Defaults.brightness}]],
+    }
+
+    def test_details(self, dev_type, method):
+        """Test the device details API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - switches),
+        device name (switches) list of device types.
+
+        Example:
+            >>> device = 'switches'
+            >>> switches = call_json_switches.SWITCHES
+
+        See Also
+        --------
+        `utils.TestBase` class docstring
+        `call_json_switches` module docstring
+
+        Notes
+        ------
+        The device is instantiated using the `call_json.DeviceList.device_list_item()`
+        method. The device details contain the default values set in `utils.Defaults`
+        """
+        # Set return value for call_api based on call_json_bulb.DETAILS_RESPONSES
+        self.mock_api.return_value = call_json_switches.DETAILS_RESPONSES[dev_type]
+
+        # Instantiate device from device list return item
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+        _, switch_obj = object_factory(dev_type,
+                                       device_config,
+                                       self.manager)
+        method_call = getattr(switch_obj, method)
+        method_call()
+
+        # Parse mock_api args tuple from arg, kwargs to kwargs
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+        # Assert device details match expected values
+        assert switch_obj.active_time == Defaults.active_time
+        if switch_obj.is_dimmable():
+            assert switch_obj.brightness == str(Defaults.brightness)
+            assert switch_obj.indicator_light_status == 'on'
+            assert switch_obj.rgb_light_status == 'on'
+            assert switch_obj.rgb_light_value == COLOR_DICT
+        self.mock_api.reset_mock()
+        self.mock_api.return_value = call_json.DETAILS_BADCODE
+        method_call()
+        assert 'details' in self.caplog.records[-1].message
+
+    def test_methods(self, dev_type, method):
+        """Test switch methods API request and response.
+
+        This method is automatically parametrized by `pytest_generate_tests`
+        based on class variables `device` (name of device type - switches),
+        device name (switches) list of device types, `base_methods` - list of
+        methods for all devices, and `device_methods` - list of methods for
+        each device type.
+
+        Example:
+            >>> base_methods = [['turn_on'], ['turn_off'], ['update']]
+            >>> device_methods = {
+                'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]]
+                }
+
+        Notes
+        -----
+        The response can be a callable that accepts the `kwargs` argument to
+        sync the device response with the API response. In some cases the API
+        returns data from the method call, such as `get_yearly_energy`, in other cases the
+        API returns a simple confirmation the command was successful.
+
+        See Also
+        --------
+        `TestBase` class method
+        `call_json_switches` module
+
+        """
+        # Get method name and kwargs from method fixture
+        method_name = method[0]
+        if len(method) == 2 and isinstance(method[1], dict):
+            method_kwargs = method[1]
+        else:
+            method_kwargs = {}
+
+        # Set return value for call_api based on call_json_switches.METHOD_RESPONSES
+        method_response = call_json_switches.METHOD_RESPONSES[dev_type][method_name]
+        if callable(method_response):
+            if method_kwargs:
+                self.mock_api.return_value = method_response(**method_kwargs)
+            else:
+                self.mock_api.return_value = method_response()
+        else:
+            self.mock_api.return_value = method_response
+
+        # Get device configuration from call_json.DeviceList.device_list_item()
+        device_config = call_json.DeviceList.device_list_item(dev_type)
+
+        # Instantiate device from device list return item
+        _, switch_obj = object_factory(dev_type,
+                                       device_config,
+                                       self.manager)
+
+        # Get method from device object
+        method_call = getattr(switch_obj, method[0])
+
+        # Ensure method runs based on device configuration
+        if method[0] == 'turn_on':
+            switch_obj.device_status = 'off'
+        elif method[0] == 'turn_off':
+            switch_obj.device_status = 'on'
+
+        # Call method with kwargs if defined
+        if method_kwargs:
+            method_call(**method_kwargs)
+        else:
+            method_call()
+
+        # Parse arguments from mock_api call into a dictionary
+        all_kwargs = parse_args(self.mock_api)
+
+        # Assert request matches recored request or write new records
+        assert_test(method_call, all_kwargs, dev_type)
+
+        self.mock_api.reset_mock()
+        self.mock_api.return_value = call_json.DETAILS_BADCODE
+        if method_kwargs:
+            assert method_call(**method_kwargs) is False
+        else:
+            assert method_call() is False
diff --git a/src/tests/test_vesync_bulbs.py b/src/tests/test_vesync_bulbs.py
deleted file mode 100644
index f077c2a..0000000
--- a/src/tests/test_vesync_bulbs.py
+++ /dev/null
@@ -1,366 +0,0 @@
-import logging
-from unittest.mock import MagicMock, patch
-from collections import namedtuple
-import pytest
-from pyvesync.helpers import RGB
-from pyvesync import VeSync, VeSyncBulbESL100, VeSyncBulbESL100CW, VeSyncBulbESL100MC, VeSyncBulbValcenoA19MC
-from . import call_json
-
-DEV_LIST = call_json.DeviceList.DEVLIST_ESL100
-
-DEV_LIST_DETAIL = call_json.DeviceList.LIST_CONF_ESL100
-
-DEV_LIST_DETAIL_CW = call_json.DeviceList.LIST_CONF_ESL100CW
-
-DEV_LIST_CW = call_json.DeviceList.DEVICE_LIST_RETURN(DEV_LIST_DETAIL_CW)
-
-DEV_LIST_DETAIL_MC = call_json.DeviceList.LIST_CONF_ESL100MC
-
-DEV_LIST_MC = call_json.DeviceList.DEVICE_LIST_RETURN(DEV_LIST_DETAIL_MC)
-
-DEV_LIST_DETAIL_VALCENO = call_json.DeviceList.LIST_CONF_VALCENO
-
-DEV_LIST_VALCENO = call_json.DeviceList.DEVICE_LIST_RETURN(DEV_LIST_DETAIL_VALCENO)
-
-DEVICE_DETAILS = call_json.DETAILS_ESL100
-
-DEVICE_DETAILS_CW = call_json.DETAILS_ESL100CW
-
-
-
-
-
-class TestVeSyncBulbESL100:
-    """Tests for VeSync dimmable bulb."""
-
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Initilize the mock VeSync class and patch call_api."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_esl100_conf(self, api_mock):
-        """Tests that Wall Switch is instantiated properly."""
-        self.mock_api.return_value = DEV_LIST
-        self.vesync_obj.get_devices()
-        bulbs = self.vesync_obj.bulbs
-        assert len(bulbs) == 1
-        bulb = bulbs[0]
-        assert isinstance(bulb, VeSyncBulbESL100)
-        assert bulb.device_name == 'Etekcity Soft White Bulb'
-        assert bulb.device_type == 'ESL100'
-        assert bulb.cid == 'ESL100-CID'
-        assert bulb.uuid == 'UUID'
-
-    def test_esl100_details(self, api_mock):
-        """Test WS get_details()."""
-        self.mock_api.return_value = DEVICE_DETAILS
-        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.vesync_obj)
-        bulb.get_details()
-        dev_details = bulb.details
-        assert bulb.device_status == 'on'
-        assert isinstance(dev_details, dict)
-        assert bulb.connection_status == 'online'
-
-    def test_esl100_no_details(self, caplog, api_mock):
-        """Test no device details for disconnected bulb."""
-        self.mock_api.return_value = ({'code': 5}, 200)
-        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.vesync_obj)
-        bulb.update()
-        assert len(caplog.records) == 1
-
-    def test_esl100_onoff(self, caplog, api_mock):
-        """Test power toggle for ESL100 bulb."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.vesync_obj)
-        assert bulb.turn_off()
-        assert bulb.turn_on()
-
-    def test_brightness(self, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.vesync_obj)
-        assert bulb.set_brightness(50)
-
-    def test_invalid_brightness(self, caplog, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.vesync_obj)
-        assert bulb.set_brightness(5000)
-        assert bulb.brightness == 100
-
-    def test_features(self, api_mock):
-        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.vesync_obj)
-        assert bulb.dimmable_feature
-        assert not bulb.color_temp_feature
-        assert not bulb.rgb_shift_feature
-
-
-class TestVeSyncBulbESL100CW:
-    """Tests for VeSync dimmable bulb."""
-
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Initilize the mock VeSync class and patch call_api."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_esl100cw_conf(self, api_mock):
-        """Tests that Wall Switch is instantiated properly."""
-        self.mock_api.return_value = DEV_LIST_CW
-        self.vesync_obj.get_devices()
-        bulbs = self.vesync_obj.bulbs
-        assert len(bulbs) == 1
-        bulb = bulbs[0]
-        assert isinstance(bulb, VeSyncBulbESL100CW)
-        assert bulb.device_name == 'ESL100CW NAME'
-        assert bulb.device_type == 'ESL100CW'
-        assert bulb.cid == 'ESL100CW-CID'
-        assert bulb.uuid == 'ESL100CW-UUID'
-
-    def test_esl100cw_details(self, api_mock):
-        """Test WS get_details()."""
-        self.mock_api.return_value = DEVICE_DETAILS_CW
-        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.vesync_obj)
-        bulb.get_details()
-        assert self.mock_api.r
-        assert bulb.device_status == 'on'
-        assert bulb.connection_status == 'online'
-
-    def test_esl100cw_onoff(self, caplog, api_mock):
-        """Test power toggle for ESL100 bulb."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.vesync_obj)
-        assert bulb.turn_off()
-        assert bulb.device_status == 'off'
-        assert bulb.turn_on()
-        assert bulb.device_status == 'on'
-
-    def test_brightness(self, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.vesync_obj)
-        assert bulb.set_brightness(50)
-        assert bulb.brightness == 50
-
-    def test_invalid_brightness(self, caplog, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.vesync_obj)
-        assert bulb.set_brightness(5000)
-        assert bulb.brightness == 100
-
-    def test_color_temp(self, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.vesync_obj)
-        assert bulb.set_color_temp(50)
-        assert bulb.color_temp_pct == 50
-
-    def test_features(self, api_mock):
-        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.vesync_obj)
-        assert bulb.dimmable_feature
-        assert bulb.color_temp_feature
-        assert not bulb.rgb_shift_feature
-
-class TestVeSyncBulbESL100MC:
-    """Tests for VeSync dimmable bulb."""
-
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Initilize the mock VeSync class and patch call_api."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_esl100mc_conf(self, api_mock):
-        """Tests that Wall Switch is instantiated properly."""
-        self.mock_api.return_value = DEV_LIST_MC
-        self.vesync_obj.get_devices()
-        bulbs = self.vesync_obj.bulbs
-        assert len(bulbs) == 1
-        bulb = bulbs[0]
-        assert isinstance(bulb, VeSyncBulbESL100MC)
-        assert bulb.device_name == 'ESL100MC NAME'
-        assert bulb.device_type == 'ESL100MC'
-        assert bulb.cid == 'CID-ESL100MC'
-        assert bulb.uuid == 'UUID-ESL100MC'
-
-    def test_esl100mc_details(self, api_mock):
-        """Test WS get_details()."""
-        self.mock_api.return_value = DEV_LIST_MC
-        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.vesync_obj)
-        bulb.get_details()
-        assert self.mock_api.r
-        assert bulb.device_status == 'on'
-        assert bulb.connection_status == 'online'
-
-    def test_esl100mc_onoff(self, caplog, api_mock):
-        """Test power toggle for ESL100 bulb."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.vesync_obj)
-        assert bulb.turn_off()
-        assert bulb.device_status == 'off'
-        assert bulb.turn_on()
-        assert bulb.device_status == 'on'
-
-    def test_brightness(self, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.vesync_obj)
-        assert bulb.set_brightness(50)
-        assert bulb.brightness == 50
-
-    def test_invalid_brightness(self, caplog, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.vesync_obj)
-        assert bulb.set_brightness(5000)
-        assert bulb.brightness == 100
-
-    def test_color(self, api_mock):
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.vesync_obj)
-        assert bulb.set_rgb(50, 100, 150)
-        assert bulb.color_rgb == namedtuple('rgb', 'red green blue')(50, 100, 150)
-
-    def test_features(self, api_mock):
-        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.vesync_obj)
-        assert bulb.dimmable_feature
-        assert not bulb.color_temp_feature
-        assert bulb.rgb_shift_feature
-
-class TestVeSyncBulbValceno:
-    """Tests for VeSync Valceno bulb."""
-
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Initilize the mock VeSync class and patch call_api."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = call_json.SAMPLE_TOKEN
-        self.vesync_obj.account_id = call_json.SAMPLE_ACTID
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_valceno_conf(self, api_mock):
-        """Tests that Valceno is instantiated properly."""
-        self.mock_api.return_value = DEV_LIST_VALCENO
-        self.vesync_obj.get_devices()
-        bulbs = self.vesync_obj.bulbs
-        assert len(bulbs) == 1
-        bulb = bulbs[0]
-        assert isinstance(bulb, VeSyncBulbValcenoA19MC)
-        assert bulb.device_name == 'VALCENO NAME'
-        assert bulb.device_type == 'XYD0001'
-        assert bulb.cid == 'CID-VALCENO'
-        assert bulb.uuid == 'UUID-VALCENO'
-
-    def test_valceno_details(self, api_mock):
-        """Test Valceno get_details()."""
-        self.mock_api.return_value = DEV_LIST_VALCENO
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        bulb.get_details()
-        assert self.mock_api.r
-        assert bulb.device_status == 'on'
-        assert bulb.connection_status == 'online'
-
-    def test_valceno_onoff(self, caplog, api_mock):
-        """Test power toggle for Valceno MC bulb."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        assert bulb.turn_off()
-        assert bulb.device_status == 'off'
-        assert bulb.turn_on()
-        assert bulb.device_status == 'on'
-
-    def test_brightness(self, api_mock):
-        """Test brightness on Valceno."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        assert bulb.set_brightness(50)
-        assert bulb.brightness == 50
-
-    def test_invalid_brightness(self, caplog, api_mock):
-        """Test invalid brightness on Valceno."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        assert bulb.set_brightness(5000)
-        assert bulb.brightness == 100
-
-    def test_color(self, api_mock):
-        """Test set color on Valceno."""
-        self.mock_api.return_value = (
-            {
-                'code': 0,
-                'msg': '',
-                'result':
-                    {'code': 0,
-                     'result': {
-                         "enabled": 'on',
-                         "colorMode": 'hsv',
-                         'brightness': 100,
-                         'hue': 5833,
-                         'saturation': 6700,
-                         'value': 59
-        }}}, 200)
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        assert bulb.set_rgb(50, 100, 150)
-        assert bulb.color_rgb == RGB(50, 100, 150)
-
-    def test_hue(self, api_mock):
-        """Test hue on Valceno MC Bulb."""
-        self.mock_api.return_value = ({'code': 0}, 200)
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        bulb.set_color_hue(230.5)
-        body_dict = {
-            "method": "setLightStatusV2",
-            "source": "APP",
-            "data":
-                {
-                    "force": 1,
-                    "brightness": "",
-                    "colorTemp": "",
-                    "colorMode": "hsv",
-                    "hue": 6403,
-                    "saturation": "",
-                    "value": ""
-                }
-        }
-        mock_call = self.mock_api.call_args[1]['json_object']['payload']
-        assert mock_call == body_dict
-
-
-    def test_features(self, api_mock):
-        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.vesync_obj)
-        assert bulb.dimmable_feature
-        assert bulb.color_temp_feature
-        assert bulb.rgb_shift_feature
\ No newline at end of file
diff --git a/src/tests/test_air_pur.py b/src/tests/test_x_air_pur.py
similarity index 57%
rename from src/tests/test_air_pur.py
rename to src/tests/test_x_air_pur.py
index 9035234..9eb51aa 100644
--- a/src/tests/test_air_pur.py
+++ b/src/tests/test_x_air_pur.py
@@ -1,60 +1,41 @@
 """Levoit Air Purifier tests."""
-
-import pytest
-from unittest.mock import patch
-import logging
 from pyvesync.vesyncfan import VeSyncAir131
-from pyvesync import VeSync
 from pyvesync.helpers import Helpers as helpers
-from . import call_json
+import call_json
+import call_json_fans
+from utils import TestBase, Defaults
 
-DEV_LIST_DETAIL = call_json.DeviceList.LIST_CONF_AIR
+LVPUR131S = 'LV-PUR131S'
 
-CORRECT_LIST = call_json.DeviceList.DEVLIST_AIR
+DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(LVPUR131S)
 
-ENERGY_HISTORY = call_json.ENERGY_HISTORY
+CORRECT_LIST = call_json.DeviceList.device_list_response(LVPUR131S)
 
-CORRECT_DETAILS = call_json.DETAILS_AIR
+CORRECT_DETAILS = call_json_fans.DETAILS_RESPONSES[LVPUR131S]
 
 BAD_LIST = call_json.DETAILS_BADCODE
 
 
-class TestVesyncAirPurifier:
+class TestVesyncAirPurifier(TestBase):
     """Air purifier tests."""
 
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Mock call_api and initialize VeSync object."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_airpur_conf(self, api_mock):
+    def test_airpur_conf(self):
         """Tests that 15A Outlet is instantiated properly."""
         self.mock_api.return_value = CORRECT_LIST
-        self.vesync_obj.get_devices()
-        fans = self.vesync_obj.fans
+        self.manager.get_devices()
+        fans = self.manager.fans
         assert len(fans) == 1
         fan = fans[0]
         assert isinstance(fan, VeSyncAir131)
-        assert fan.device_name == 'Name Air Purifier'
-        assert fan.device_type == 'LV-PUR131S'
-        assert fan.cid == 'AIRPUR-CID'
-        assert fan.uuid == 'UUID'
+        assert fan.device_name == Defaults.name(LVPUR131S)
+        assert fan.device_type == LVPUR131S
+        assert fan.cid == Defaults.cid(LVPUR131S)
+        assert fan.uuid == Defaults.uuid(LVPUR131S)
 
-    def test_airpur_details(self, api_mock):
+    def test_airpur_details(self):
         """Test 15A get_details()."""
         self.mock_api.return_value = CORRECT_DETAILS
-        fan = VeSyncAir131(DEV_LIST_DETAIL, self.vesync_obj)
+        fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager)
         fan.get_details()
         dev_details = fan.details
         assert fan.device_status == 'on'
@@ -68,20 +49,20 @@ def test_airpur_details(self, api_mock):
         assert dev_details['air_quality'] == 'excellent'
         assert fan.air_quality == 'excellent'
 
-    def test_airpur_details_fail(self, caplog, api_mock):
+    def test_airpur_details_fail(self):
         """Test Air Purifier get_details with Code>0."""
         self.mock_api.return_value = BAD_LIST
-        fan = VeSyncAir131(DEV_LIST_DETAIL, self.vesync_obj)
+        fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager)
         fan.get_details()
-        assert len(caplog.records) == 1
-        assert 'details' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'details' in self.caplog.text
 
-    def test_airpur_onoff(self, caplog, api_mock):
+    def test_airpur_onoff(self):
         """Test Air Purifier Device On/Off Methods."""
         self.mock_api.return_value = ({'code': 0}, 200)
-        fan = VeSyncAir131(DEV_LIST_DETAIL, self.vesync_obj)
-        head = helpers.req_headers(self.vesync_obj)
-        body = helpers.req_body(self.vesync_obj, 'devicestatus')
+        fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager)
+        head = helpers.req_headers(self.manager)
+        body = helpers.req_body(self.manager, 'devicestatus')
         fan.device_status = 'off'
         body['status'] = 'on'
         body['uuid'] = fan.uuid
@@ -101,17 +82,18 @@ def test_airpur_onoff(self, caplog, api_mock):
         )
         assert off
 
-    def test_airpur_onoff_fail(self, api_mock):
+    def test_airpur_onoff_fail(self):
         """Test Air Purifier On/Off Fail with Code>0."""
         self.mock_api.return_value = ({'code': 1}, 400)
-        vsfan = VeSyncAir131(DEV_LIST_DETAIL, self.vesync_obj)
+        vsfan = VeSyncAir131(DEV_LIST_DETAIL, self.manager)
         assert not vsfan.turn_on()
         assert not vsfan.turn_off()
 
-    def test_airpur_fanspeed(self, caplog, api_mock):
+    def test_airpur_fanspeed(self):
         """Test changing fan speed of."""
         self.mock_api.return_value = ({'code': 0}, 200)
-        fan = VeSyncAir131(DEV_LIST_DETAIL, self.vesync_obj)
+        fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager)
+        fan.mode = 'manual'
         fan.details['level'] = 1
         b = fan.change_fan_speed()
         assert fan.fan_level == 2
@@ -124,10 +106,10 @@ def test_airpur_fanspeed(self, caplog, api_mock):
         assert b
         assert fan.fan_level == 2
 
-    def test_mode_toggle(self, caplog, api_mock):
+    def test_mode_toggle(self):
         """Test changing modes on air purifier."""
         self.mock_api.return_value = ({'code': 0}, 200)
-        fan = VeSyncAir131(DEV_LIST_DETAIL, self.vesync_obj)
+        fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager)
         f = fan.auto_mode()
         assert f
         assert fan.mode == 'auto'
diff --git a/src/tests/test_vesync.py b/src/tests/test_x_vesync.py
similarity index 93%
rename from src/tests/test_vesync.py
rename to src/tests/test_x_vesync.py
index ae96ee5..4f9ba17 100644
--- a/src/tests/test_vesync.py
+++ b/src/tests/test_x_vesync.py
@@ -157,16 +157,5 @@ def test_api_bad_response(self, api_mock):
         print(api_mock.call_args_list)
         assert mock_return == (None, None)
 
-    @patch('pyvesync.helpers.requests.get', autospec=True)
-    def test_api_exception(self, api_mock, caplog):
-        """Test call_api method exception handling."""
-        caplog.set_level(logging.DEBUG)
-        api_mock.return_value.raiseError.side_effect = Mock(side_effect=Exception)
-
-        Helpers.call_api('/call/location', method='get')
-
-        assert len(caplog.records) == 6
-
-
 if __name__ == '__main__':
     unittest.main()
diff --git a/src/tests/test_vesync_10a.py b/src/tests/test_x_vesync_10a.py
similarity index 52%
rename from src/tests/test_vesync_10a.py
rename to src/tests/test_x_vesync_10a.py
index e643e48..3b84e69 100644
--- a/src/tests/test_vesync_10a.py
+++ b/src/tests/test_x_vesync_10a.py
@@ -3,87 +3,78 @@
 import pytest
 from unittest.mock import patch
 import logging
-from pyvesync import VeSync, VeSyncOutlet10A
+from pyvesync import VeSync
+from pyvesync.vesyncoutlet import VeSyncOutlet10A
 from pyvesync.helpers import Helpers as helpers
-from . import call_json
+import call_json
+import call_json_outlets
+from utils import Defaults, TestBase
 
-DEV_LIST_DETAIL_EU = call_json.DeviceList.LIST_CONF_10AEU
-DEV_LIST_DETAIL_US = call_json.DeviceList.LIST_CONF_10AUS
-CORRECT_10AUS_LIST = call_json.DeviceList.DEVLIST_10AUS
-CORRECT_10AEU_LIST = call_json.DeviceList.DEVLIST_10AEU
+OutletDefaults = call_json_outlets.OutletDefaults
 
-ENERGY_HISTORY = call_json.ENERGY_HISTORY
+DEV_TYPE_US = 'ESW03-USA'
+DEV_TYPE_EU = 'ESW01-EU'
+DEV_LIST_DETAIL_EU = call_json.DeviceList.device_list_item(DEV_TYPE_EU)
+DEV_LIST_DETAIL_US = call_json.DeviceList.device_list_item(DEV_TYPE_US)
+CORRECT_10AUS_LIST = call_json.DeviceList.device_list_response(DEV_TYPE_US)
+CORRECT_10AEU_LIST = call_json.DeviceList.device_list_response(DEV_TYPE_EU)
 
-CORRECT_10A_DETAILS = call_json.DETAILS_10A
+ENERGY_HISTORY = call_json_outlets.ENERGY_HISTORY
+
+CORRECT_10A_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEV_TYPE_US]
 
 BAD_10A_LIST = call_json.DETAILS_BADCODE
 
 
-class TestVesync10ASwitch(object):
+class TestVesync10ASwitch(TestBase):
     """Test class for 10A outlets."""
 
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Mock call_api() method and initialize VeSync object."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
     @pytest.mark.parametrize(
         'mock_return, devtype',
-        [(CORRECT_10AEU_LIST, 'ESW01-EU'), (CORRECT_10AUS_LIST, 'ESW03-USA')],
+        [(CORRECT_10AEU_LIST, DEV_TYPE_EU), (CORRECT_10AUS_LIST, DEV_TYPE_US)],
     )
-    def test_10a_conf(self, mock_return, devtype, api_mock):
+    def test_10a_conf(self, mock_return, devtype):
         """Tests that 10A US & EU Outlet is instantiated properly."""
         self.mock_api.return_value = mock_return
-        self.vesync_obj.get_devices()
-        outlets = self.vesync_obj.outlets
+        self.manager.get_devices()
+        outlets = self.manager.outlets
         assert len(outlets) == 1
         outlet = outlets[0]
         assert isinstance(outlet, VeSyncOutlet10A)
-        assert outlet.device_name == 'Name 10A Outlet'
+        assert outlet.device_name == Defaults.name(devtype)
         assert outlet.device_type == devtype
-        assert outlet.cid == '10A-CID'
-        assert outlet.uuid == 'UUID'
+        assert outlet.cid == Defaults.cid(devtype)
+        assert outlet.uuid == Defaults.uuid(devtype)
 
-    def test_10a_details(self, api_mock):
+    def test_10a_details(self):
         """Test 10A get_details()."""
         self.mock_api.return_value = CORRECT_10A_DETAILS
-        outlet = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.vesync_obj)
+        outlet = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager)
         outlet.get_details()
         dev_details = outlet.details
         assert outlet.device_status == 'on'
         assert type(dev_details) == dict
-        assert dev_details['active_time'] == 1
-        assert dev_details['energy'] == 1
-        assert dev_details['power'] == '1'
-        assert dev_details['voltage'] == '1'
-        assert outlet.power == 1
-        assert outlet.voltage == 1
-
-    def test_10a_details_fail(self, caplog, api_mock):
+        assert dev_details['active_time'] == Defaults.active_time
+        assert dev_details['energy'] == OutletDefaults.energy
+        assert dev_details['power'] == OutletDefaults.power
+        assert dev_details['voltage'] == OutletDefaults.voltage
+        assert outlet.power == OutletDefaults.power
+        assert outlet.voltage == OutletDefaults.voltage
+
+    def test_10a_details_fail(self):
         """Test 10A get_details with Code>0."""
         self.mock_api.return_value = BAD_10A_LIST
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.vesync_obj)
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager)
         out.get_details()
-        assert len(caplog.records) == 1
-        assert 'details' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'details' in self.caplog.text
 
-    def test_10a_onoff(self, caplog, api_mock):
+    def test_10a_onoff(self):
         """Test 10A Device On/Off Methods."""
         self.mock_api.return_value = ({'code': 0}, 200)
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.vesync_obj)
-        head = helpers.req_headers(self.vesync_obj)
-        body = helpers.req_body(self.vesync_obj, 'devicestatus')
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager)
+        head = helpers.req_headers(self.manager)
+        body = helpers.req_body(self.manager, 'devicestatus')
 
         body['status'] = 'on'
         body['uuid'] = out.uuid
@@ -99,24 +90,24 @@ def test_10a_onoff(self, caplog, api_mock):
         )
         assert off
 
-    def test_10a_onoff_fail(self, api_mock):
+    def test_10a_onoff_fail(self):
         """Test 10A On/Off Fail with Code>0."""
         self.mock_api.return_value = ({'code': 1}, 400)
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.vesync_obj)
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager)
         assert not out.turn_on()
         assert not out.turn_off()
 
-    def test_10a_weekly(self, api_mock):
+    def test_10a_weekly(self):
         """Test 10A get_weekly_energy."""
         self.mock_api.return_value = ENERGY_HISTORY
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.vesync_obj)
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager)
         out.get_weekly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_week')
+        body = helpers.req_body(self.manager, 'energy_week')
         body['uuid'] = out.uuid
         self.mock_api.assert_called_with(
             '/10a/v1/device/energyweek',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = out.energy['week']
@@ -127,17 +118,17 @@ def test_10a_weekly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert out.weekly_energy_total == 1
 
-    def test_10a_monthly(self, api_mock):
+    def test_10a_monthly(self):
         """Test 10A get_monthly_energy."""
         self.mock_api.return_value = ENERGY_HISTORY
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.vesync_obj)
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager)
         out.get_monthly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_month')
+        body = helpers.req_body(self.manager, 'energy_month')
         body['uuid'] = out.uuid
         self.mock_api.assert_called_with(
             '/10a/v1/device/energymonth',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = out.energy['month']
@@ -148,17 +139,17 @@ def test_10a_monthly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert out.monthly_energy_total == 1
 
-    def test_10a_yearly(self, api_mock):
+    def test_10a_yearly(self):
         """Test 10A get_yearly_energy."""
         self.mock_api.return_value = ENERGY_HISTORY
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.vesync_obj)
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager)
         out.get_yearly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_year')
+        body = helpers.req_body(self.manager, 'energy_year')
         body['uuid'] = out.uuid
         self.mock_api.assert_called_with(
             '/10a/v1/device/energyyear',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = out.energy['year']
@@ -169,19 +160,19 @@ def test_10a_yearly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert out.yearly_energy_total == 1
 
-    def test_history_fail(self, caplog, api_mock):
+    def test_history_fail(self):
         """Test 15A energy failure."""
         bad_history = {'code': 1}
         self.mock_api.return_value = (bad_history, 200)
-        out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.vesync_obj)
+        out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager)
         out.update_energy()
-        assert len(caplog.records) == 1
-        assert 'weekly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'weekly' in self.caplog.text
+        self.caplog.clear()
         out.get_monthly_energy()
-        assert len(caplog.records) == 1
-        assert 'monthly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'monthly' in self.caplog.text
+        self.caplog.clear()
         out.get_yearly_energy()
-        assert len(caplog.records) == 1
-        assert 'yearly' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'yearly' in self.caplog.text
diff --git a/src/tests/test_vesync_15a.py b/src/tests/test_x_vesync_15a.py
similarity index 56%
rename from src/tests/test_vesync_15a.py
rename to src/tests/test_x_vesync_15a.py
index 2adb28a..1822cb5 100644
--- a/src/tests/test_vesync_15a.py
+++ b/src/tests/test_x_vesync_15a.py
@@ -1,54 +1,41 @@
-import pytest
-from unittest.mock import patch
-import logging
-from pyvesync import VeSync, VeSyncOutlet15A
+from pyvesync.vesyncoutlet import VeSyncOutlet15A
 from pyvesync.helpers import Helpers as helpers
-from . import call_json
+import call_json
+import utils
+import call_json_outlets
 
-DEV_LIST_DETAIL = call_json.DeviceList.LIST_CONF_15A
+DEVICE_TYPE = 'ESW15-USA'
 
-CORRECT_15A_LIST = call_json.DeviceList.DEVLIST_15A
+DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_TYPE)
 
-ENERGY_HISTORY = call_json.ENERGY_HISTORY
+CORRECT_15A_LIST = call_json.DeviceList.device_list_response(DEVICE_TYPE)
 
-CORRECT_15A_DETAILS = call_json.DETAILS_15A
+ENERGY_HISTORY = call_json_outlets.ENERGY_HISTORY
+
+CORRECT_15A_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE]
 
 BAD_15A_LIST = call_json.DETAILS_BADCODE
 
 
-class TestVesync15ASwitch(object):
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_15aswitch_conf(self, api_mock):
+class TestVeSyncSwitch(utils.TestBase):
+
+    def test_15aswitch_conf(self):
         """Tests that 15A Outlet is instantiated properly"""
         self.mock_api.return_value = CORRECT_15A_LIST
-        self.vesync_obj.get_devices()
-        outlets = self.vesync_obj.outlets
+        self.manager.get_devices()
+        outlets = self.manager.outlets
         assert len(outlets) == 1
         vswitch15a = outlets[0]
         assert isinstance(vswitch15a, VeSyncOutlet15A)
-        assert vswitch15a.device_name == 'Name 15A Outlet'
-        assert vswitch15a.device_type == 'ESW15-USA'
-        assert vswitch15a.cid == '15A-CID'
-        assert vswitch15a.uuid == 'UUID'
+        assert vswitch15a.device_name == call_json.Defaults.name(DEVICE_TYPE)
+        assert vswitch15a.device_type == DEVICE_TYPE
+        assert vswitch15a.cid == call_json.Defaults.cid(DEVICE_TYPE)
+        assert vswitch15a.uuid == call_json.Defaults.uuid(DEVICE_TYPE)
 
-    def test_15a_details(self, api_mock):
+    def test_15a_details(self):
         """Test 15A get_details() """
         self.mock_api.return_value = CORRECT_15A_DETAILS
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.get_details()
         dev_details = vswitch15a.details
         assert vswitch15a.device_status == 'on'
@@ -62,28 +49,44 @@ def test_15a_details(self, api_mock):
         assert vswitch15a.active_time == 1
         assert vswitch15a.energy_today == 1
 
-    def test_15a_details_fail(self, caplog, api_mock):
+
+class TestVesync15ASwitch(utils.TestBase):
+
+    def test_15aswitch_conf(self):
+        """Tests that 15A Outlet is instantiated properly"""
+        self.mock_api.return_value = CORRECT_15A_LIST
+        self.manager.get_devices()
+        outlets = self.manager.outlets
+        assert len(outlets) == 1
+        vswitch15a = outlets[0]
+        assert isinstance(vswitch15a, VeSyncOutlet15A)
+        assert vswitch15a.device_name == call_json.Defaults.name(DEVICE_TYPE)
+        assert vswitch15a.device_type == DEVICE_TYPE
+        assert vswitch15a.cid == call_json.Defaults.cid(DEVICE_TYPE)
+        assert vswitch15a.uuid == call_json.Defaults.uuid(DEVICE_TYPE)
+
+    def test_15a_details_fail(self):
         """Test 15A get_details with Code>0"""
         self.mock_api.return_value = BAD_15A_LIST
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.get_details()
-        assert len(caplog.records) == 1
-        assert 'details' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'details' in self.caplog.text
 
-    def test_15a_no_details(self, caplog, api_mock):
+    def test_15a_no_details(self):
         """Test 15A details return with no details and code=0"""
         bad_15a_details = {'code': 0, 'deviceStatus': 'on'}
         self.mock_api.return_value = (bad_15a_details, 200)
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.get_details()
-        assert len(caplog.records) == 1
+        assert len(self.caplog.records) == 1
 
-    def test_15a_onoff(self, caplog, api_mock):
+    def test_15a_onoff(self):
         """Test 15A Device On/Off Methods"""
         self.mock_api.return_value = ({'code': 0}, 200)
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
-        head = helpers.req_headers(self.vesync_obj)
-        body = helpers.req_body(self.vesync_obj, 'devicestatus')
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
+        head = helpers.req_headers(self.manager)
+        body = helpers.req_body(self.manager, 'devicestatus')
 
         body['status'] = 'on'
         body['uuid'] = vswitch15a.uuid
@@ -99,24 +102,24 @@ def test_15a_onoff(self, caplog, api_mock):
         )
         assert off
 
-    def test_15a_onoff_fail(self, api_mock):
+    def test_15a_onoff_fail(self):
         """Test 15A On/Off Fail with Code>0"""
         self.mock_api.return_value = ({'code': 1}, 400)
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         assert not vswitch15a.turn_on()
         assert not vswitch15a.turn_off()
 
-    def test_15a_weekly(self, api_mock):
+    def test_15a_weekly(self):
         """Test 15A get_weekly_energy"""
         self.mock_api.return_value = ENERGY_HISTORY
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.get_weekly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_week')
+        body = helpers.req_body(self.manager, 'energy_week')
         body['uuid'] = vswitch15a.uuid
         self.mock_api.assert_called_with(
             '/15a/v1/device/energyweek',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = vswitch15a.energy['week']
@@ -127,17 +130,17 @@ def test_15a_weekly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert vswitch15a.weekly_energy_total == 1
 
-    def test_15a_monthly(self, api_mock):
+    def test_15a_monthly(self):
         """Test 15A get_monthly_energy"""
         self.mock_api.return_value = ENERGY_HISTORY
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.get_monthly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_month')
+        body = helpers.req_body(self.manager, 'energy_month')
         body['uuid'] = vswitch15a.uuid
         self.mock_api.assert_called_with(
             '/15a/v1/device/energymonth',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = vswitch15a.energy['month']
@@ -148,17 +151,17 @@ def test_15a_monthly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert vswitch15a.monthly_energy_total == 1
 
-    def test_15a_yearly(self, api_mock):
+    def test_15a_yearly(self):
         """Test 15A get_yearly_energy"""
         self.mock_api.return_value = ENERGY_HISTORY
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.get_yearly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_year')
+        body = helpers.req_body(self.manager, 'energy_year')
         body['uuid'] = vswitch15a.uuid
         self.mock_api.assert_called_with(
             '/15a/v1/device/energyyear',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = vswitch15a.energy['year']
@@ -169,19 +172,19 @@ def test_15a_yearly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert vswitch15a.yearly_energy_total == 1
 
-    def test_history_fail(self, caplog, api_mock):
+    def test_history_fail(self):
         """Test 15A energy failure"""
         bad_history = {'code': 1}
         self.mock_api.return_value = (bad_history, 200)
-        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager)
         vswitch15a.update_energy()
-        assert len(caplog.records) == 1
-        assert 'weekly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'weekly' in self.caplog.text
+        self.caplog.clear()
         vswitch15a.get_monthly_energy()
-        assert len(caplog.records) == 1
-        assert 'monthly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'monthly' in self.caplog.text
+        self.caplog.clear()
         vswitch15a.get_yearly_energy()
-        assert len(caplog.records) == 1
-        assert 'yearly' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'yearly' in self.caplog.text
diff --git a/src/tests/test_vesync_7aswitch.py b/src/tests/test_x_vesync_7aswitch.py
similarity index 58%
rename from src/tests/test_vesync_7aswitch.py
rename to src/tests/test_x_vesync_7aswitch.py
index 719b0d1..c8dbf8d 100644
--- a/src/tests/test_vesync_7aswitch.py
+++ b/src/tests/test_x_vesync_7aswitch.py
@@ -3,57 +3,52 @@
 import logging
 from unittest.mock import patch
 import pytest
-import pyvesync
-from pyvesync import VeSync, VeSyncOutlet7A
+from pyvesync import VeSync
+from pyvesync.vesyncoutlet import VeSyncOutlet7A
 from pyvesync.helpers import Helpers as helpers
+import call_json
+import call_json_outlets
+from utils import Defaults, TestBase
 
-from . import call_json
+DEVICE_TYPE = 'wifi-switch-1.3'
 
-DEV_LIST_DETAIL = call_json.DeviceList.LIST_CONF_7A
+DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_TYPE)
 
-CORRECT_7A_LIST = call_json.DeviceList.DEVLIST_7A
+CORRECT_7A_LIST = call_json.DeviceList.device_list_response(DEVICE_TYPE)
 
-CORRECT_7A_DETAILS = call_json.DETAILS_7A
+CORRECT_7A_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE]
 
-ENERGY_HISTORY = call_json.ENERGY_HISTORY
+ENERGY_HISTORY = call_json_outlets.ENERGY_HISTORY
 
+DEVICE_TYPE = 'wifi-switch-1.3'
 
-class TestVesync7ASwitch(object):
+CALL_LIST = [
+    'turn_on',
+    'turn_off',
+    'update'
+]
+
+
+class TestVesync7ASwitch(TestBase):
     """Test 7A outlet API."""
 
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Mock call_api() and initialize VeSync object."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospect()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.tk = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_7aswitch_conf(self, api_mock):
+    def test_7aswitch_conf(self):
         """Test inizialization of 7A outlet."""
         self.mock_api.return_value = CORRECT_7A_LIST
-        self.vesync_obj.get_devices()
-        outlets = self.vesync_obj.outlets
+        self.manager.get_devices()
+        outlets = self.manager.outlets
         assert len(outlets) == 1
         vswitch7a = outlets[0]
         assert isinstance(vswitch7a, VeSyncOutlet7A)
-        assert vswitch7a.device_name == 'Name 7A Outlet'
-        assert vswitch7a.device_type == 'wifi-switch-1.3'
-        assert vswitch7a.cid == '7A-CID'
+        assert vswitch7a.device_name == call_json.Defaults.name(DEVICE_TYPE)
+        assert vswitch7a.device_type == DEVICE_TYPE
+        assert vswitch7a.cid == call_json.Defaults.cid(DEVICE_TYPE)
         assert vswitch7a.is_on
 
-    def test_7a_details(self, api_mock):
+    def test_7a_details(self):
         """Test get_details() method for 7A outlet."""
         self.mock_api.return_value = CORRECT_7A_DETAILS
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.get_details()
         dev_details = vswitch7a.details
         assert vswitch7a.device_status == 'on'
@@ -63,7 +58,7 @@ def test_7a_details(self, api_mock):
         assert vswitch7a.power == 1
         assert vswitch7a.voltage == 1
 
-    def test_7a_no_devstatus(self, caplog, api_mock):
+    def test_7a_no_devstatus(self):
         """Test 7A outlet details response with no device status key."""
         bad_7a_details = {
             'deviceImg': '',
@@ -73,25 +68,25 @@ def test_7a_no_devstatus(self, caplog, api_mock):
             'voltage': '1A:1A',
         }
         self.mock_api.return_value = (bad_7a_details, 200)
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.get_details()
-        assert len(caplog.records) == 1
-        assert 'details' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'details' in self.caplog.text
 
-    def test_7a_no_details(self, caplog, api_mock):
+    def test_7a_no_details(self):
         """Test 7A outlet details response with unknown keys."""
         bad_7a_details = {'wrongdetails': 'on'}
         self.mock_api.return_value = (bad_7a_details, 200)
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.get_details()
-        assert len(caplog.records) == 1
+        assert len(self.caplog.records) == 1
 
-    def test_7a_onoff(self, caplog, api_mock):
+    def test_7a_onoff(self):
         """Test 7A outlet on/off methods."""
         self.mock_api.return_value = ('response', 200)
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         on = vswitch7a.turn_on()
-        head = helpers.req_headers(self.vesync_obj)
+        head = helpers.req_headers(self.manager)
         self.mock_api.assert_called_with(
             '/v1/wifi-switch-1.3/' + vswitch7a.cid + '/status/on', 'put', headers=head
         )
@@ -102,22 +97,22 @@ def test_7a_onoff(self, caplog, api_mock):
         )
         assert off
 
-    def test_7a_onoff_fail(self, api_mock):
+    def test_7a_onoff_fail(self):
         """Test 7A outlet on/off methods that fail."""
         self.mock_api.return_value = ('response', 400)
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         assert not vswitch7a.turn_on()
         assert not vswitch7a.turn_off()
 
-    def test_7a_weekly(self, api_mock):
+    def test_7a_weekly(self):
         """Test 7A outlet weekly energy API call and energy dict."""
         self.mock_api.return_value = ENERGY_HISTORY
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.get_weekly_energy()
         self.mock_api.assert_called_with(
             '/v1/device/' + vswitch7a.cid + '/energy/week',
             'get',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
         )
         energy_dict = vswitch7a.energy['week']
         assert energy_dict['energy_consumption_of_today'] == 1
@@ -126,15 +121,15 @@ def test_7a_weekly(self, api_mock):
         assert energy_dict['total_energy'] == 1
         assert energy_dict['data'] == [1, 1]
 
-    def test_7a_monthly(self, api_mock):
+    def test_7a_monthly(self):
         """Test 7A outlet monthly energy API call and energy dict."""
         self.mock_api.return_value = ENERGY_HISTORY
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.get_monthly_energy()
         self.mock_api.assert_called_with(
             '/v1/device/' + vswitch7a.cid + '/energy/month',
             'get',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
         )
         energy_dict = vswitch7a.energy['month']
         assert energy_dict['energy_consumption_of_today'] == 1
@@ -143,15 +138,15 @@ def test_7a_monthly(self, api_mock):
         assert energy_dict['total_energy'] == 1
         assert energy_dict['data'] == [1, 1]
 
-    def test_7a_yearly(self, api_mock):
+    def test_7a_yearly(self):
         """Test 7A outlet yearly energy API call and energy dict."""
         self.mock_api.return_value = ENERGY_HISTORY
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.get_yearly_energy()
         self.mock_api.assert_called_with(
             '/v1/device/' + vswitch7a.cid + '/energy/year',
             'get',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
         )
         energy_dict = vswitch7a.energy['year']
         assert energy_dict['energy_consumption_of_today'] == 1
@@ -160,19 +155,19 @@ def test_7a_yearly(self, api_mock):
         assert energy_dict['total_energy'] == 1
         assert energy_dict['data'] == [1, 1]
 
-    def test_history_fail(self, caplog, api_mock):
+    def test_history_fail(self):
         """Test handling of energy update failure."""
         bad_history = {'code': 1}
         self.mock_api.return_value = (bad_history, 200)
-        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.vesync_obj)
+        vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager)
         vswitch7a.update_energy()
-        assert len(caplog.records) == 1
-        assert 'weekly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'weekly' in self.caplog.text
+        self.caplog.clear()
         vswitch7a.get_monthly_energy()
-        assert len(caplog.records) == 1
-        assert 'monthly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'monthly' in self.caplog.text
+        self.caplog.clear()
         vswitch7a.get_yearly_energy()
-        assert len(caplog.records) == 1
-        assert 'yearly' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'yearly' in self.caplog.text
diff --git a/src/tests/test_x_vesync_bulbs.py b/src/tests/test_x_vesync_bulbs.py
new file mode 100644
index 0000000..83a38d5
--- /dev/null
+++ b/src/tests/test_x_vesync_bulbs.py
@@ -0,0 +1,338 @@
+from collections import namedtuple
+from pyvesync.helpers import RGB
+from pyvesync.vesyncbulb import (VeSyncBulbESL100, VeSyncBulbESL100CW,
+                                 VeSyncBulbESL100MC, VeSyncBulbValcenoA19MC)
+import call_json
+import call_json_bulbs
+from utils import TestBase
+
+DEV_LIST = call_json.DeviceList.device_list_response('ESL100')
+
+DEV_LIST_DETAIL = call_json.DeviceList.device_list_item('ESL100')
+
+DEV_LIST_DETAIL_CW = call_json.DeviceList.device_list_item('ESL100CW')
+
+DEV_LIST_CW = call_json.DeviceList.device_list_response('ESL100CW')
+
+DEV_LIST_DETAIL_MC = call_json.DeviceList.device_list_item('ESL100MC')
+
+DEV_LIST_MC = call_json.DeviceList.device_list_response('ESL100MC')
+
+DEV_LIST_DETAIL_VALCENO = call_json.DeviceList.device_list_item('XYD0001')
+
+DEV_LIST_VALCENO = call_json.DeviceList.device_list_response('XYD0001')
+
+DEVICE_DETAILS = call_json_bulbs.DETAILS_RESPONSES['ESL100']
+
+DEVICE_DETAILS_CW = call_json_bulbs.DETAILS_RESPONSES['ESL100CW']
+
+DEFAULTS = call_json.Defaults
+
+
+class TestVeSyncBulbESL100(TestBase):
+    """Tests for VeSync dimmable bulb."""
+    device_type = 'ESL100'
+
+    def test_esl100_conf(self):
+        """Tests that Wall Switch is instantiated properly."""
+        self.mock_api.return_value = DEV_LIST
+        self.manager.get_devices()
+        bulbs = self.manager.bulbs
+        assert len(bulbs) == 1
+        bulb = bulbs[0]
+        assert isinstance(bulb, VeSyncBulbESL100)
+        assert bulb.device_name == call_json.Defaults.name(self.device_type)
+        assert bulb.device_type == self.device_type
+        assert bulb.cid == call_json.Defaults.cid(self.device_type)
+        assert bulb.uuid == call_json.Defaults.uuid(self.device_type)
+
+    def test_esl100_details(self):
+        """Test WS get_details()."""
+        if callable(DEVICE_DETAILS):
+            self.mock_api.return_value = DEVICE_DETAILS()
+        else:
+            self.mock_api.return_value = DEVICE_DETAILS
+        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager)
+        bulb.get_details()
+        dev_details = bulb.details
+        assert bulb.device_status == 'on'
+        assert isinstance(dev_details, dict)
+        assert bulb.connection_status == 'online'
+
+    def test_esl100_no_details(self):
+        """Test no device details for disconnected bulb."""
+        self.mock_api.return_value = ({'code': 5}, 200)
+        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager)
+        bulb.update()
+        assert len(self.caplog.records) == 1
+
+    def test_esl100_onoff(self):
+        """Test power toggle for ESL100 bulb."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager)
+        assert bulb.turn_off()
+        assert bulb.turn_on()
+
+    def test_brightness(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager)
+        assert bulb.set_brightness(50)
+
+    def test_invalid_brightness(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager)
+        assert bulb.set_brightness(5000)
+        assert bulb.brightness == 100
+
+    def test_features(self):
+        bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager)
+        assert bulb.dimmable_feature
+        assert not bulb.color_temp_feature
+        assert not bulb.rgb_shift_feature
+
+
+class TestVeSyncBulbESL100CW(TestBase):
+    """Tests for VeSync dimmable bulb."""
+    device_type = 'ESL100CW'
+
+    def test_esl100cw_conf(self):
+        """Tests that Wall Switch is instantiated properly."""
+        if callable(DEV_LIST_CW):
+            self.mock_api.return_value = DEV_LIST_CW()
+        else:
+            self.mock_api.return_value = DEV_LIST_CW
+        self.manager.get_devices()
+        bulbs = self.manager.bulbs
+        assert len(bulbs) == 1
+        bulb = bulbs[0]
+        assert isinstance(bulb, VeSyncBulbESL100CW)
+        assert bulb.device_name == DEFAULTS.name(self.device_type)
+        assert bulb.device_type == self.device_type
+        assert bulb.cid == DEFAULTS.cid(self.device_type)
+        assert bulb.uuid == DEFAULTS.uuid(self.device_type)
+
+    def test_esl100cw_details(self):
+        """Test WS get_details()."""
+        if callable(DEVICE_DETAILS_CW):
+            self.mock_api.return_value = DEVICE_DETAILS_CW()
+        else:
+            self.mock_api.return_value = DEVICE_DETAILS_CW
+        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager)
+        bulb.get_details()
+        assert self.mock_api.r
+        assert bulb.device_status == 'on'
+        assert bulb.connection_status == 'online'
+
+    def test_esl100cw_onoff(self):
+        """Test power toggle for ESL100 bulb."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager)
+        assert bulb.turn_off()
+        assert bulb.device_status == 'off'
+        assert bulb.turn_on()
+        assert bulb.device_status == 'on'
+
+    def test_brightness(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager)
+        assert bulb.set_brightness(50)
+        assert bulb.brightness == 50
+
+    def test_invalid_brightness(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager)
+        assert bulb.set_brightness(5000)
+        assert bulb.brightness == 100
+
+    def test_color_temp(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager)
+        assert bulb.set_color_temp(50)
+        assert bulb.color_temp_pct == 50
+
+    def test_features(self):
+        bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager)
+        assert bulb.dimmable_feature
+        assert bulb.color_temp_feature
+        assert not bulb.rgb_shift_feature
+
+
+class TestVeSyncBulbESL100MC(TestBase):
+    """Tests for VeSync dimmable bulb."""
+    device_type = 'ESL100MC'
+
+    def test_esl100mc_conf(self):
+        """Tests that Wall Switch is instantiated properly."""
+        self.mock_api.return_value = DEV_LIST_MC
+        self.manager.get_devices()
+        bulbs = self.manager.bulbs
+        assert len(bulbs) == 1
+        bulb = bulbs[0]
+        assert isinstance(bulb, VeSyncBulbESL100MC)
+        assert bulb.device_name == DEFAULTS.name(self.device_type)
+        assert bulb.device_type == self.device_type
+        assert bulb.cid == DEFAULTS.cid(self.device_type)
+        assert bulb.uuid == DEFAULTS.uuid(self.device_type)
+
+    def test_esl100mc_details(self):
+        """Test WS get_details()."""
+        self.mock_api.return_value = DEV_LIST_MC
+        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager)
+        bulb.get_details()
+        assert self.mock_api.r
+        assert bulb.device_status == 'on'
+        assert bulb.connection_status == 'online'
+
+    def test_esl100mc_onoff(self):
+        """Test power toggle for ESL100 bulb."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager)
+        assert bulb.turn_off()
+        assert bulb.device_status == 'off'
+        assert bulb.turn_on()
+        assert bulb.device_status == 'on'
+
+    def test_brightness(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager)
+        assert bulb.set_brightness(50)
+        assert bulb.brightness == 50
+
+    def test_invalid_brightness(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager)
+        assert bulb.set_brightness(5000)
+        assert bulb.brightness == 100
+
+    def test_color(self):
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager)
+        assert bulb.set_rgb(50, 100, 150)
+        assert bulb.color_rgb == namedtuple('rgb', 'red green blue')(50, 100, 150)
+
+    def test_features(self):
+        bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager)
+        assert bulb.dimmable_feature
+        assert not bulb.color_temp_feature
+        assert bulb.rgb_shift_feature
+
+
+class TestVeSyncBulbValceno(TestBase):
+    """Tests for VeSync Valceno bulb."""
+    device_type = 'XYD0001'
+
+    def test_valceno_conf(self):
+        """Tests that Valceno is instantiated properly."""
+        self.mock_api.return_value = DEV_LIST_VALCENO
+        self.manager.get_devices()
+        bulbs = self.manager.bulbs
+        assert len(bulbs) == 1
+        bulb = bulbs[0]
+        assert isinstance(bulb, VeSyncBulbValcenoA19MC)
+        assert bulb.device_name == DEFAULTS.name(self.device_type)
+        assert bulb.device_type == self.device_type
+        assert bulb.cid == DEFAULTS.cid(self.device_type)
+        assert bulb.uuid == DEFAULTS.uuid(self.device_type)
+
+    def test_valceno_details(self):
+        """Test Valceno get_details()."""
+        self.mock_api.return_value = DEV_LIST_VALCENO
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        bulb.get_details()
+        assert self.mock_api.r
+        assert bulb.device_status == 'on'
+        assert bulb.connection_status == 'online'
+
+    def test_valceno_onoff(self):
+        """Test power toggle for Valceno MC bulb."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        assert bulb.turn_off()
+        assert bulb.device_status == 'off'
+        assert bulb.turn_on()
+        assert bulb.device_status == 'on'
+
+    def test_brightness(self):
+        """Test brightness on Valceno."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        assert bulb.set_brightness(50)
+        assert bulb.brightness == 50
+
+    def test_invalid_brightness(self):
+        """Test invalid brightness on Valceno."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        assert bulb.set_brightness(5000)
+        assert bulb.brightness == 100
+        
+    def test_invalid_saturation(self):
+        """Test invalid saturation on Valceno."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        assert bulb.set_color_saturation(5000)
+        body_dict = {
+            "method": "setLightStatusV2",
+            "source": "APP",
+            "data":
+                {
+                    "force": 1,
+                    "brightness": "",
+                    "colorTemp": "",
+                    "colorMode": "hsv",
+                    "hue": "",
+                    "saturation": 10000,
+                    "value": ""
+                }
+        }
+        mock_call = self.mock_api.call_args[1]['json_object']['payload']
+        assert mock_call == body_dict
+
+    def test_color(self):
+        """Test set color on Valceno."""
+        self.mock_api.return_value = ({
+            'code': 0,
+            'msg': '',
+            'result': {
+                'code': 0,
+                'result': {
+                    "enabled": 'on',
+                    "colorMode": 'hsv',
+                    'brightness': 100,
+                    'hue': 5833,
+                    'saturation': 6700,
+                    'value': 59
+                }
+            }
+        }, 200)
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        assert bulb.set_rgb(50, 100, 150)
+        assert bulb.color_rgb == RGB(50, 100, 150)
+
+    def test_hue(self):
+        """Test hue on Valceno MC Bulb."""
+        self.mock_api.return_value = ({'code': 0}, 200)
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        bulb.set_color_hue(230.5)
+        body_dict = {
+            "method": "setLightStatusV2",
+            "source": "APP",
+            "data":
+                {
+                    "force": 1,
+                    "brightness": "",
+                    "colorTemp": "",
+                    "colorMode": "hsv",
+                    "hue": 6403,
+                    "saturation": "",
+                    "value": ""
+                }
+        }
+        mock_call = self.mock_api.call_args[1]['json_object']['payload']
+        assert mock_call == body_dict
+
+    def test_features(self):
+        bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager)
+        assert bulb.dimmable_feature
+        assert bulb.color_temp_feature
+        assert bulb.rgb_shift_feature
diff --git a/src/tests/test_vesync_devices.py b/src/tests/test_x_vesync_devices.py
similarity index 91%
rename from src/tests/test_vesync_devices.py
rename to src/tests/test_x_vesync_devices.py
index b04d5a9..4638a7b 100644
--- a/src/tests/test_vesync_devices.py
+++ b/src/tests/test_x_vesync_devices.py
@@ -11,7 +11,11 @@
 from pyvesync.vesyncbulb import *
 from pyvesync.vesyncoutlet import *
 from pyvesync.vesyncswitch import *
-from . import call_json as json_vals
+import call_json as json_vals
+from call_json_switches import SWITCHES_NUM
+from call_json_outlets import OUTLETS_NUM
+from call_json_fans import FANS_NUM
+from call_json_bulbs import BULBS_NUM
 
 BAD_DEV_LIST = {
     'result': {
@@ -27,14 +31,14 @@
 class TestDeviceList(object):
     """Test getting and populating device lists."""
 
-    @pytest.fixture()
+    @pytest.fixture(scope='function')
     def api_mock(self, caplog):
         """Mock call_api and initialize VeSync object."""
         self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
         self.mock_api = self.mock_api_call.start()
         self.mock_api.create_autospec()
         self.mock_api.return_value.ok = True
-        self.vesync_obj = pyvesync.vesync.VeSync('sam@mail.com', 'pass')
+        self.vesync_obj = pyvesync.vesync.VeSync('sam@mail.com', 'pass', debug=True)
         self.vesync_obj.enabled = True
         self.vesync_obj.token = 'sample_tk'
         self.vesync_obj.account_id = 'sample_id'
@@ -70,16 +74,16 @@ def test_getdevs_vsfact(
         Test for all 6 known devices - 4 outlets, 2 switches, 1 fan.
         """
 
-        device_list = json_vals.DeviceList.DEVLIST_ALL
+        device_list = json_vals.DeviceList.device_list_response()
 
         self.mock_api.return_value = device_list
 
         self.vesync_obj.get_devices()
 
-        assert len(self.vesync_obj.outlets) == 6
-        assert len(self.vesync_obj.switches) == 2
-        assert len(self.vesync_obj.fans) == 4
-        assert len(self.vesync_obj.bulbs) == 4
+        assert len(self.vesync_obj.outlets) == OUTLETS_NUM
+        assert len(self.vesync_obj.switches) == SWITCHES_NUM
+        assert len(self.vesync_obj.fans) == FANS_NUM
+        assert len(self.vesync_obj.bulbs) == BULBS_NUM
 
     def test_lv600(
             self, api_mock
@@ -97,7 +101,6 @@ def test_lv600(
 
         assert len(self.vesync_obj.fans) == 3
 
-
     def test_dual200s(
             self, api_mock
     ):
@@ -106,7 +109,7 @@ def test_dual200s(
         Test for all 6 known devices - 4 outlets, 2 switches, 1 fan.
         """
 
-        device_list = json_vals.DeviceList.DEVLIST_DUAL200S
+        device_list = json_vals.DeviceList.device_list_response('Dual200S')
 
         self.mock_api.return_value = device_list
         self.vesync_obj.debug = True
@@ -114,7 +117,6 @@ def test_dual200s(
 
         assert len(self.vesync_obj.fans) == 1
 
-
     def test_getdevs_code(self, caplog, api_mock):
         """Test get_devices with code > 0 returned."""
         device_list = ({'code': 1, 'msg': 'gibberish'}, 200)
@@ -225,7 +227,7 @@ def test_remove_device(self, outlet_patch, caplog, api_mock):
     @patch('pyvesync.vesync.VeSyncOutdoorPlug', autospec=True)
     def test_add_dev_test(self, outdoor_patch, caplog, api_mock):
         """Test add_device_test to return if device found in existing conf."""
-        outdoor_inst = pyvesync.VeSyncOutdoorPlug(
+        outdoor_inst = VeSyncOutdoorPlug(
             json_vals.DeviceList.LIST_CONF_OUTDOOR_2, self.vesync_obj
         )
         self.vesync_obj.outlets = [outdoor_inst]
@@ -237,13 +239,13 @@ def test_add_dev_test(self, outdoor_patch, caplog, api_mock):
     def test_display_func(self, caplog, api_mock):
         """Test display function outputs text."""
         self.vesync_obj.outlets.append(
-            VeSyncOutdoorPlug(json_vals.DeviceList.LIST_CONF_OUTDOOR_1, self.vesync_obj)
+            VeSyncOutdoorPlug(json_vals.DeviceList.device_list_item('ESO15-TB', 0), self.vesync_obj)
         )
         self.vesync_obj.outlets.append(
-            VeSyncOutlet10A(json_vals.DeviceList.LIST_CONF_10AUS, self.vesync_obj)
+            VeSyncOutlet10A(json_vals.DeviceList.device_list_item('ESW01-EU'), self.vesync_obj)
         )
         self.vesync_obj.outlets.append(
-            VeSyncOutlet15A(json_vals.DeviceList.LIST_CONF_15A, self.vesync_obj)
+            VeSyncOutlet15A(json_vals.DeviceList.device_list_item('ESW15-USA'), self.vesync_obj)
         )
         self.vesync_obj.outlets.append(
             VeSyncOutlet7A(json_vals.DeviceList.LIST_CONF_7A, self.vesync_obj)
diff --git a/src/tests/test_vesync_login.py b/src/tests/test_x_vesync_login.py
similarity index 79%
rename from src/tests/test_vesync_login.py
rename to src/tests/test_x_vesync_login.py
index 30a5abc..92b1f08 100644
--- a/src/tests/test_vesync_login.py
+++ b/src/tests/test_x_vesync_login.py
@@ -80,25 +80,3 @@ def test_good_login(self, api_mock):
         self.mock_api.assert_called_with('/cloud/v1/user/login', 'post', json_object=jd)
         assert vesync_obj.token == 'sam_token'
         assert vesync_obj.account_id == 'sam_actid'
-
-
-@pytest.mark.parametrize('email, password, testid', login_bad_call)
-@patch('pyvesync.helpers.requests.post')
-def test_login(mock_api, email, password, testid):
-    """Test multiple failed login calls."""
-    return_tuple = {'code': 455, 'msg': 'sdasd'}
-    mock_api.return_value.ok = True
-    mock_api.return_value.json.return_value = return_tuple
-    vesync_obj = VeSync(email, password)
-    vesync_login = vesync_obj.login()
-    assert vesync_login is False
-    if testid == 'correct':
-        jd = helpers.req_body(vesync_obj, 'login')
-        mock_api.assert_called_with(
-            'https://smartapi.vesync.com/cloud/v1/user/login',
-            headers=None,
-            json=jd,
-            timeout=5,
-        )
-    else:
-        assert not mock_api.called
diff --git a/src/tests/test_vesync_outdoor.py b/src/tests/test_x_vesync_outdoor.py
similarity index 61%
rename from src/tests/test_vesync_outdoor.py
rename to src/tests/test_x_vesync_outdoor.py
index a179e6e..6f88284 100644
--- a/src/tests/test_vesync_outdoor.py
+++ b/src/tests/test_x_vesync_outdoor.py
@@ -1,113 +1,106 @@
 """Test scripts for Etekcity Outdoor Outlet."""
-
-import pytest
-from unittest.mock import patch
-import logging
-from pyvesync import VeSync, VeSyncOutdoorPlug
+from typing import Any, Dict, Union
+from copy import deepcopy
+from pyvesync.vesyncoutlet import VeSyncOutdoorPlug
 from pyvesync.helpers import Helpers as helpers
-from . import call_json
+import call_json
+import call_json_outlets
+from utils import TestBase, Defaults
+
+DEVICE_TYPE = 'ESO15-TB'
+
+DEV_LIST_DETAIL: Dict[str, Union[str, int, float]] = call_json.DeviceList.device_list_item(DEVICE_TYPE, 0)
+
+DEV_LIST_DETAIL_2: Dict[str, Any] = call_json.DeviceList.device_list_item(DEVICE_TYPE, 1)
 
-DEV_LIST_DETAIL = call_json.DeviceList.LIST_CONF_OUTDOOR_1
+CORRECT_OUTDOOR_LIST: Dict[str, Any] = deepcopy(call_json.DeviceList.list_response_base)
+CORRECT_OUTDOOR_LIST['result']['list'].extend([DEV_LIST_DETAIL, DEV_LIST_DETAIL_2])
+CORRECT_OUTDOOR_RESP: tuple = (CORRECT_OUTDOOR_LIST, 200)
 
-DEV_LIST_DETAIL_2 = call_json.DeviceList.LIST_CONF_OUTDOOR_2
 
-CORRECT_OUTDOOR_LIST = call_json.DeviceList.DEVLIST_OUTDOOR
+ENERGY_HISTORY: tuple = call_json_outlets.ENERGY_HISTORY
 
-ENERGY_HISTORY = call_json.ENERGY_HISTORY
+CORRECT_OUTDOOR_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE]
 
-CORRECT_OUTDOOR_DETAILS = call_json.DETAILS_OUTDOOR
+BAD_OUTDOOR_LIST: tuple = call_json.DETAILS_BADCODE
 
-BAD_OUTDOOR_LIST = call_json.DETAILS_BADCODE
+DEFAULTS = Defaults
 
 
-class TestVesyncOutdoorPlug:
+class TestVesyncOutdoorPlug(TestBase):
     """Test class for outdoor outlet."""
 
-    @pytest.fixture()
-    def api_mock(self, caplog):
-        """Mock call_api and initialize VeSync object."""
-        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
-        self.mock_api = self.mock_api_call.start()
-        self.mock_api.create_autospec()
-        self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
-        self.vesync_obj.enabled = True
-        self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
-        caplog.set_level(logging.DEBUG)
-        yield
-        self.mock_api_call.stop()
-
-    def test_outdoor_conf(self, api_mock):
+    def test_outdoor_conf(self):
         """Tests outdoor outlet is instantiated properly."""
-        self.mock_api.return_value = CORRECT_OUTDOOR_LIST
-        self.vesync_obj.get_devices()
-        outlets = self.vesync_obj.outlets
+        self.mock_api.return_value = CORRECT_OUTDOOR_RESP
+        self.manager.get_devices()
+        outlets = self.manager.outlets
         assert len(outlets) == 2
         outdoor_outlet = outlets[0]
         assert isinstance(outdoor_outlet, VeSyncOutdoorPlug)
-        assert outdoor_outlet.device_type == 'ESO15-TB'
-        assert outdoor_outlet.uuid == 'UUID'
+        assert outdoor_outlet.device_type == DEVICE_TYPE
+        assert outdoor_outlet.uuid == DEFAULTS.uuid(DEVICE_TYPE)
 
-    def test_outdoor_details(self, api_mock):
+    def test_outdoor_details(self):
         """Tests retrieving outdoor outlet details."""
         self.mock_api.return_value = CORRECT_OUTDOOR_DETAILS
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         outdoor_outlet.get_details()
         dev_details = outdoor_outlet.details
         assert outdoor_outlet.device_status == 'on'
         assert isinstance(outdoor_outlet, VeSyncOutdoorPlug)
         assert dev_details['active_time'] == 1
 
-    def test_outdoor_details_fail(self, caplog, api_mock):
+    def test_outdoor_details_fail(self, caplog):
         """Test outdoor outlet get_details response."""
         self.mock_api.return_value = BAD_OUTDOOR_LIST
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         outdoor_outlet.get_details()
         assert len(caplog.records) == 1
         assert 'details' in caplog.text
 
-    def test_outdoor_outlet_onoff(self, caplog, api_mock):
+    def test_outdoor_outlet_onoff(self):
         """Test Outdoor Outlet Device On/Off Methods."""
         self.mock_api.return_value = ({'code': 0}, 200)
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
-        head = helpers.req_headers(self.vesync_obj)
-        body = helpers.req_body(self.vesync_obj, 'devicestatus')
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
+        head = helpers.req_headers(self.manager)
+        body = helpers.req_body(self.manager, 'devicestatus')
 
         body['status'] = 'on'
         body['uuid'] = outdoor_outlet.uuid
         body['switchNo'] = outdoor_outlet.sub_device_no
         on = outdoor_outlet.turn_on()
         self.mock_api.assert_called_with(
-            '/outdoorsocket15a/v1/device/devicestatus', 'put', headers=head, json_object=body
+            '/outdoorsocket15a/v1/device/devicestatus', 'put',
+            headers=head, json_object=body
         )
         assert on
         off = outdoor_outlet.turn_off()
         body['status'] = 'off'
         self.mock_api.assert_called_with(
-            '/outdoorsocket15a/v1/device/devicestatus', 'put', headers=head, json_object=body
+            '/outdoorsocket15a/v1/device/devicestatus', 'put',
+            headers=head, json_object=body
         )
         assert off
 
-    def test_outdoor_outlet_onoff_fail(self, api_mock):
+    def test_outdoor_outlet_onoff_fail(self):
         """Test outdoor outlet On/Off Fail with Code>0."""
         self.mock_api.return_value = ({'code': 1}, 400)
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         assert not outdoor_outlet.turn_on()
         assert not outdoor_outlet.turn_off()
 
-    def test_outdoor_outlet_weekly(self, api_mock):
+    def test_outdoor_outlet_weekly(self):
         """Test outdoor outlet get_weekly_energy."""
         self.mock_api.return_value = ENERGY_HISTORY
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         outdoor_outlet.get_weekly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_week')
+        body = helpers.req_body(self.manager, 'energy_week')
         body['uuid'] = outdoor_outlet.uuid
         self.mock_api.assert_called_with(
             '/outdoorsocket15a/v1/device/energyweek',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = outdoor_outlet.energy['week']
@@ -118,17 +111,17 @@ def test_outdoor_outlet_weekly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert outdoor_outlet.weekly_energy_total == 1
 
-    def test_outdoor_outlet_monthly(self, api_mock):
+    def test_outdoor_outlet_monthly(self):
         """Test outdoor outlet get_monthly_energy."""
         self.mock_api.return_value = ENERGY_HISTORY
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         outdoor_outlet.get_monthly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_month')
+        body = helpers.req_body(self.manager, 'energy_month')
         body['uuid'] = outdoor_outlet.uuid
         self.mock_api.assert_called_with(
             '/outdoorsocket15a/v1/device/energymonth',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = outdoor_outlet.energy['month']
@@ -139,17 +132,17 @@ def test_outdoor_outlet_monthly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert outdoor_outlet.monthly_energy_total == 1
 
-    def test_outdoor_outlet_yearly(self, api_mock):
+    def test_outdoor_outlet_yearly(self):
         """Test outdoor outlet get_yearly_energy."""
         self.mock_api.return_value = ENERGY_HISTORY
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         outdoor_outlet.get_yearly_energy()
-        body = helpers.req_body(self.vesync_obj, 'energy_year')
+        body = helpers.req_body(self.manager, 'energy_year')
         body['uuid'] = outdoor_outlet.uuid
         self.mock_api.assert_called_with(
             '/outdoorsocket15a/v1/device/energyyear',
             'post',
-            headers=helpers.req_headers(self.vesync_obj),
+            headers=helpers.req_headers(self.manager),
             json_object=body,
         )
         energy_dict = outdoor_outlet.energy['year']
@@ -160,19 +153,19 @@ def test_outdoor_outlet_yearly(self, api_mock):
         assert energy_dict['data'] == [1, 1]
         assert outdoor_outlet.yearly_energy_total == 1
 
-    def test_history_fail(self, caplog, api_mock):
+    def test_history_fail(self):
         """Test outdoor outlet energy failure."""
         bad_history = {'code': 1}
         self.mock_api.return_value = (bad_history, 200)
-        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.vesync_obj)
+        outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager)
         outdoor_outlet.update_energy()
-        assert len(caplog.records) == 1
-        assert 'weekly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'weekly' in self.caplog.text
+        self.caplog.clear()
         outdoor_outlet.get_monthly_energy()
-        assert len(caplog.records) == 1
-        assert 'monthly' in caplog.text
-        caplog.clear()
+        assert len(self.caplog.records) == 1
+        assert 'monthly' in self.caplog.text
+        self.caplog.clear()
         outdoor_outlet.get_yearly_energy()
-        assert len(caplog.records) == 1
-        assert 'yearly' in caplog.text
+        assert len(self.caplog.records) == 1
+        assert 'yearly' in self.caplog.text
diff --git a/src/tests/test_wall_switch.py b/src/tests/test_x_wall_switch.py
similarity index 78%
rename from src/tests/test_wall_switch.py
rename to src/tests/test_x_wall_switch.py
index ea7faf3..8bba010 100644
--- a/src/tests/test_wall_switch.py
+++ b/src/tests/test_x_wall_switch.py
@@ -1,20 +1,24 @@
 import pytest
 from unittest.mock import patch
 import logging
-from pyvesync import VeSync, VeSyncWallSwitch
+from pyvesync import VeSync
+from pyvesync.vesyncswitch import VeSyncWallSwitch
 from pyvesync.helpers import Helpers as helpers
-from . import call_json
+import call_json
+import call_json_switches
 
-DEV_LIST_DETAIL = call_json.DeviceList.LIST_CONF_WS
+DEVICE_TYPE = 'ESWL01'
 
-CORRECT_WS_LIST = call_json.DeviceList.DEVLIST_WS
+DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_TYPE)
 
-ENERGY_HISTORY = call_json.ENERGY_HISTORY
+CORRECT_WS_LIST = call_json.DeviceList.device_list_response(DEVICE_TYPE)
 
-CORRECT_WS_DETAILS = call_json.DETAILS_WS
+CORRECT_WS_DETAILS = call_json_switches.DETAILS_RESPONSES[DEVICE_TYPE]
 
 BAD_LIST = call_json.DETAILS_BADCODE
 
+DEFAULTS = call_json.Defaults
+
 
 class TestVesyncWallSwitch(object):
     @pytest.fixture()
@@ -23,11 +27,11 @@ def api_mock(self, caplog):
         self.mock_api = self.mock_api_call.start()
         self.mock_api.create_autospect()
         self.mock_api.return_value.ok = True
-        self.vesync_obj = VeSync('sam@mail.com', 'pass')
+        self.vesync_obj = VeSync('sam@mail.com', 'pass', debug=True)
         self.vesync_obj.enabled = True
         self.vesync_obj.login = True
-        self.vesync_obj.token = 'sample_tk'
-        self.vesync_obj.account_id = 'sample_actid'
+        self.vesync_obj.token = DEFAULTS.token
+        self.vesync_obj.account_id = DEFAULTS.account_id
         caplog.set_level(logging.DEBUG)
         yield
         self.mock_api_call.stop()
@@ -40,10 +44,10 @@ def test_ws_conf(self, api_mock):
         assert len(switch) == 1
         wswitch = switch[0]
         assert isinstance(wswitch, VeSyncWallSwitch)
-        assert wswitch.device_name == 'Name Wall Switch'
-        assert wswitch.device_type == 'ESWL01'
-        assert wswitch.cid == 'WS-CID'
-        assert wswitch.uuid == 'UUID'
+        assert wswitch.device_name == DEFAULTS.name(DEVICE_TYPE)
+        assert wswitch.device_type == DEVICE_TYPE
+        assert wswitch.cid == DEFAULTS.cid(DEVICE_TYPE)
+        assert wswitch.uuid == DEFAULTS.uuid(DEVICE_TYPE)
 
     def test_ws_details(self, api_mock):
         """Test WS get_details() """
diff --git a/src/tests/utils.py b/src/tests/utils.py
new file mode 100644
index 0000000..d240246
--- /dev/null
+++ b/src/tests/utils.py
@@ -0,0 +1,413 @@
+"""Common base class for tests and Default values.
+
+Routine Listings
+----------------
+
+FunctionResponses : defaultdict
+    Defaultdict of the standard response tuple for device methods
+Defaults: class
+    Default values and methods for generating default values
+TestBase: class
+    Base class for tests to start mock & instantiat VS object
+"""
+import logging
+from pathlib import Path
+import pytest
+import yaml
+from collections import defaultdict
+from unittest.mock import patch
+from requests.structures import CaseInsensitiveDict
+from pyvesync.vesync import VeSync
+from pyvesync.helpers import Color
+import pyvesync.helpers as vs_helpers
+
+
+logger = logging.getLogger(__name__)
+
+FunctionResponses: defaultdict = defaultdict(lambda: ({"code": 0, "msg": None}, 200))
+
+CALL_API_ARGS = ['url', 'method', 'data', 'headers']
+
+ID_KEYS = ['CID', 'UUID', 'MACID']
+
+
+class Defaults:
+    """General defaults for API responses and requests.
+
+    Attributes
+    ----------
+    token : str
+        Default token for API requests
+    active_time : str
+        Default active time for API responses
+    account_id : str
+        Default account ID for API responses
+    active_time : str
+        Default active time for API responses
+    color: Color (dataclass)
+        Red=50, Green=100, Blue=225, Hue=223, Saturation=77.78, value=88.24
+        Default Color dataclass
+        contains red, green, blue, hue, saturation and value attributes
+
+    Methods
+    --------
+    name(dev_type='NA')
+        Default device name created from "dev_type-NAME"
+    cid(dev_type='NA')
+        Default device cid created from "dev_type-CID"
+    uuid(dev_type='NA')
+        Default device uuid created from "dev_type-UUID"
+    macid(dev_type='NA')
+        Default device macid created from "dev_type-MACID"
+    """
+
+    token = 'sample_tk'
+    account_id = 'sample_id'
+    trace_id = "TRACE_ID"
+    active_time = 1
+    color = Color(red=50, green=100, blue=225)
+    brightness = 100
+    color_temp = 100
+
+    @staticmethod
+    def name(dev_type: str = 'NA'):
+        """Name of device with format f"{dev_type}-NAME".
+
+        Parameters
+        ----------
+        dev_type : str
+            Device type use to create default name
+
+        Returns
+        -------
+        str
+            Default name for device f"dev_type-NAME"
+        """
+        return f'{dev_type}-NAME'
+
+    @staticmethod
+    def cid(dev_type='NA'):
+        """CID for a device with format f"{dev_type}-CID".
+
+        Parameters
+        ----------
+        dev_type : str
+            Device type use to create default cid
+
+        Returns
+        -------
+        str
+            Default cid for device f"dev_type-CID"
+        """
+        return f'{dev_type}-CID'
+
+    @staticmethod
+    def uuid(dev_type='NA'):
+        """UUID for a device with format f"{dev_type}-UUID".
+
+        Parameters
+        ----------
+        dev_type : str
+            Device type use to create default UUID
+
+        Returns
+        -------
+        str
+            Default uuid for device f"{dev_type}-UUID"
+        """
+        return f'{dev_type}-UUID'
+
+    @staticmethod
+    def macid(dev_type='NA'):
+        """MACID for a device with format f"{dev_type}-MACID".
+
+        Parameters
+        ----------
+        dev_type : str
+            Device type use to create default macid
+
+        Returns
+        -------
+        str
+            Default macID for device f"{dev_type}-MACID"
+        """
+        return f'{dev_type}-MACID'
+
+
+def parse_args(mock_api):
+    """Parse arguments from mock API call.
+
+    Arguments
+    ----------
+    mock_api : mock
+        Mock object used to path call_api() method
+
+    Returns
+    -------
+    dict
+        dictionary of all call_api() arguments
+    """
+    call_args = mock_api.call_args.args
+    call_kwargs = mock_api.call_args.kwargs
+    all_kwargs = dict(zip(CALL_API_ARGS, call_args))
+    all_kwargs.update(call_kwargs)
+    return all_kwargs
+
+
+API_DEFAULTS = CaseInsensitiveDict({
+    'accountID': Defaults.account_id,
+    'token': Defaults.token,
+    'timeZone': vs_helpers.DEFAULT_TZ,
+    'acceptLanguage': 'en',
+    'appVersion': vs_helpers.APP_VERSION,
+    'phoneBrand': vs_helpers.PHONE_BRAND,
+    'phoneOS': vs_helpers.PHONE_OS,
+    'userType': vs_helpers.USER_TYPE,
+    "tk": Defaults.token,
+    "traceId": "TRACE_ID",
+    'verifyEmail': 'EMAIL',
+    'nickName': 'NICKNAME',
+    'password': 'PASSWORD',
+    'username': 'EMAIL',
+    'email': 'EMAIL',
+    'deviceName': 'NAME'
+
+})
+
+
+class YAMLWriter:
+    """Read and Write API request data to YAML files.
+
+    Arguments
+    ---------
+    module : str
+        name of module that is being tested
+    dev_type : str
+        device type being tested
+
+    Attributes
+    ----------
+    self.file_path : Path
+        Path to YAML directory, default to API dir in tests folder
+    self.file : Path
+        Path to YAML file based on device type and module
+    self.existings_yaml : dict
+        Existing YAML data read to dict object
+    self._existing_api : dict, optional
+        Existing data dict of a specific API call
+
+    Methods
+    -------
+    self.existing_api()
+        Return existing data dict of a specific API call or None
+    self.write_api(api, data, overwrite=False)
+        Writes data to YAML file for a specific API call,
+        set overwrite=True to overwrite existing data
+
+    """
+
+    def __init__(self, module, dev_type):
+        """Init the YAMLWriter class.
+
+        Arguments
+        ----------
+        module : str
+            name of module that is being tested
+        dev_type : str
+            device type being tested
+        """
+        self.file_path = self._get_path(module)
+        self.file = Path.joinpath(self.file_path, dev_type + '.yaml')
+        self.new_file = self._new_file()
+        self.existing_yaml = self._get_existing_yaml()
+        self._existing_api = None
+
+    @staticmethod
+    def _get_path(module):
+        yaml_dir = Path.joinpath(Path(__file__).parent, 'api', module)
+        if not yaml_dir.exists():
+            yaml_dir.mkdir(parents=True)
+        return yaml_dir
+
+    def _new_file(self):
+        if not self.file.exists():
+            logger.debug(f'Creating new file {self.file}')
+            self.file.touch()
+            return True
+        return False
+
+    def _get_existing_yaml(self):
+        if self.new_file:
+            return None
+        with open(self.file, 'rb') as f:
+            data = yaml.full_load(f)
+        return data
+
+    def existing_api(self, method):
+        """Check YAML file for existing data for API call.
+
+        Arguments
+        ----------
+        method : str
+            Name of method being tested
+
+        Returns
+        -------
+        dict or None
+            Existing data for API call or None
+        """
+        if self.existing_yaml is not None:
+            current_dict = self.existing_yaml.get(method)
+            self._existing_api = current_dict
+            if current_dict is not None:
+                logger.debug(f'API call {method} already exists in {self.file}')
+                return True
+            return current_dict
+        return None
+
+    def write_api(self, method, yaml_dict, overwrite=False):
+        """Write API data to YAML file.
+
+        Arguments
+        ----------
+        method : str
+            Name of method being tested
+        yaml_dict : dict
+            Data to write to YAML file
+        overwrite : bool, optional
+            Overwrite existing data, default to False
+        """
+        if self.existing_yaml is not None:
+            current_dict = self.existing_yaml.get(method)
+            if current_dict is not None and overwrite is False:
+                logger.debug(f'API call {method} already exists in {self.file}')
+                return
+            self.existing_yaml[method] = yaml_dict
+        else:
+            self.existing_yaml = {method: yaml_dict}
+        with open(self.file, 'w', encoding='utf-8') as f:
+            yaml.dump(self.existing_yaml, f, encoding='utf-8')
+
+
+def api_scrub(api_dict, device_type=None):
+    """Recursive function to scrub all sensitive data from API call.
+
+    Arguments
+    ----------
+    api_dict : dict
+        API call data to scrub
+    device_type : str, optional
+        Device type to use for default values
+
+    Returns
+    -------
+    dict
+        Scrubbed API call data
+    """
+    def id_cleaner(key, value):
+        if key.upper() in ID_KEYS:
+            return f"{device_type or 'UNKNOWN'}-{key.upper()}"
+        if key in API_DEFAULTS:
+            return API_DEFAULTS[key]
+        return value
+
+    def nested_dict_iter(nested, mapper, last_key=None):
+        if isinstance(nested, dict):
+            if nested.get('deviceType') is not None:
+                nonlocal device_type
+                device_type = nested['deviceType']
+            out = {}
+            for key, value in nested.items():
+                out[key] = nested_dict_iter(value, mapper, key)
+            return out
+
+        if isinstance(nested, list):
+            return [nested_dict_iter(el, mapper, last_key) for el in nested]
+        if not last_key:
+            return nested
+        return mapper(last_key, nested)
+    return nested_dict_iter(api_dict, id_cleaner)
+
+
+class TestBase:
+    """Base class for all tests.
+
+    Contains instantiated VeSync object and mocked
+    API call for call_api() function.
+
+    Attributes
+    ----------
+    self.mock_api : Mock
+        Mock for call_api() function
+    self.manager : VeSync
+        Instantiated VeSync object that is logged in
+    self.caplog : LogCaptureFixture
+        Pytest fixture for capturing logs
+    """
+
+    @pytest.fixture(autouse=True, scope='function')
+    def setup(self, caplog):
+        """Fixture to instantiate VeSync object, start logging and start Mock.
+
+        Attributes
+        ----------
+        self.mock_api : Mock
+        self.manager : VeSync
+        self.caplog : LogCaptureFixture
+
+        Yields
+        ------
+        Class instance with mocked call_api() function and VeSync object
+        """
+        self.mock_api_call = patch('pyvesync.helpers.Helpers.call_api')
+        self.caplog = caplog
+        self.mock_api = self.mock_api_call.start()
+        self.mock_api.create_autospect()
+        self.mock_api.return_value.ok = True
+        self.manager = VeSync('EMAIL', 'PASSWORD', debug=True)
+        self.manager.enabled = True
+        self.manager.token = Defaults.token
+        self.manager.account_id = Defaults.account_id
+        caplog.set_level(logging.DEBUG)
+        yield
+        self.mock_api_call.stop()
+
+
+def assert_test(test_func, all_kwargs, dev_type=None, overwrite=False):
+    """Test function for tests run on API request data.
+
+    Arguments
+    ----------
+    test_func : method
+        Method that is being tested
+    all_kwargs : dict
+        Dictionary of call_api() arguments
+    dev_type : str, optional
+        Device type being tested
+    overwrite : bool, optional
+        Overwrite existing data, default to False
+
+    Returns
+    -------
+    bool
+        True if test passes, False if test fails
+
+    Asserts
+    -------
+        Asserts that the API call data matches the expected data
+    """
+    if all_kwargs.get('json_object') is not None:
+        all_kwargs['json_object'] = api_scrub(all_kwargs['json_object'], dev_type)
+    if all_kwargs.get('headers') is not None:
+        all_kwargs['headers'] = api_scrub(all_kwargs['headers'], dev_type)
+    mod = test_func.__module__.split(".")[-1]
+    if dev_type is None:
+        cls_name = test_func.__self__.__class__.__name__
+    else:
+        cls_name = dev_type
+    method_name = test_func.__name__
+    writer = YAMLWriter(mod, cls_name)
+    if writer.existing_api(method_name) is not None:
+        assert writer._existing_api == all_kwargs
+    writer.write_api(method_name, all_kwargs, overwrite)
+    return True