diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml
new file mode 100644
index 0000000..a3e903c
--- /dev/null
+++ b/.github/workflows/python-package.yaml
@@ -0,0 +1,43 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
+
+name: Python package
+
+on:
+  push:
+    branches: [ "master" ]
+  pull_request:
+    branches: [ "master" ]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.12", "3.13"]
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install flake8 pytest build
+        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+        # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    - name: Build
+      run: |
+        python -m build
+    # - name: Test with pytest
+    #   run: |
+    #     pytest
diff --git a/.gitignore b/.gitignore
index a65d046..c0c4d43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+secrets.cfg
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
@@ -56,3 +57,6 @@ docs/_build/
 
 # PyBuilder
 target/
+
+.idea/
+venv/
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 5c98b42..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-# Default ignored files
-/workspace.xml
\ No newline at end of file
diff --git a/.idea/ReolinkCameraAPI.iml b/.idea/ReolinkCameraAPI.iml
deleted file mode 100644
index 6711606..0000000
--- a/.idea/ReolinkCameraAPI.iml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-  
-    
-    
-    
-  
-  
-    
-  
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index a55e7a1..0000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-  
-    
-  
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-  
-    
-    
-  
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 3999087..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-  
-    
-  
-  
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 8125795..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-  
-    
-      
-    
-  
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-  
-    
-  
-
\ No newline at end of file
diff --git a/APIHandler.py b/APIHandler.py
deleted file mode 100644
index b855c43..0000000
--- a/APIHandler.py
+++ /dev/null
@@ -1,917 +0,0 @@
-import io
-import json
-import random
-import string
-import sys
-from urllib import request
-
-import numpy
-import rtsp
-from PIL import Image
-
-from RtspClient import RtspClient
-from resthandle import Request
-
-
-class APIHandler:
-    """
-    The APIHandler class is the backend part of the API.
-    This handles communication directly with the camera.
-    Current camera's tested: RLC-411WS
-
-    All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/
-
-    """
-
-    def __init__(self, ip: str, username: str, password: str, https = False, **kwargs):
-        """
-        Initialise the Camera API Handler (maps api calls into python)
-        :param ip:
-        :param username:
-        :param password:
-        :param proxy: Add a proxy dict for requests to consume.
-        eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
-        More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
-        """
-        scheme = 'https' if https else 'http'
-        self.url = f"{scheme}://{ip}/cgi-bin/api.cgi"
-        self.ip = ip
-        self.token = None
-        self.username = username
-        self.password = password
-        Request.proxies = kwargs.get("proxy")  # Defaults to None if key isn't found
-
-    ###########
-    # Token
-    ###########
-
-    def login(self) -> bool:
-        """
-        Get login token
-        Must be called first, before any other operation can be performed
-        :return: bool
-        """
-        try:
-            body = [{"cmd": "Login", "action": 0,
-                     "param": {"User": {"userName": self.username, "password": self.password}}}]
-            param = {"cmd": "Login", "token": "null"}
-            response = Request.post(self.url, data=body, params=param)
-            if response is not None:
-                data = json.loads(response.text)[0]
-                code = data["code"]
-                if int(code) == 0:
-                    self.token = data["value"]["Token"]["name"]
-                    print("Login success")
-                    return True
-                print(self.token)
-                return False
-            else:
-                print("Failed to login\nStatus Code:", response.status_code)
-                return False
-        except Exception as e:
-            print("Error Login\n", e)
-            raise
-
-    ###########
-    # NETWORK
-    ###########
-
-    ###########
-    # SET Network
-    ###########
-    def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935,
-                     rtsp_port=554) -> bool:
-        """
-        Set network ports
-        If nothing is specified, the default values will be used
-        :param rtsp_port: int
-        :param rtmp_port: int
-        :param onvif_port: int
-        :param media_port: int
-        :param https_port: int
-        :type http_port: int
-        :return: bool
-        """
-        try:
-            if self.token is None:
-                raise ValueError("Login first")
-
-            body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": {
-                "httpPort": http_port,
-                "httpsPort": https_port,
-                "mediaPort": media_port,
-                "onvifPort": onvif_port,
-                "rtmpPort": rtmp_port,
-                "rtspPort": rtsp_port
-            }}}]
-            param = {"token": self.token}
-            response = Request.post(self.url, data=body, params=param)
-            if response is not None:
-                if response.status_code == 200:
-                    print("Successfully Set Network Ports")
-                    return True
-                else:
-                    print("Something went wront\nStatus Code:", response.status_code)
-                    return False
-            return False
-        except Exception as e:
-            print("Setting Network Port Error\n", e)
-            raise
-
-    def set_wifi(self, ssid, password) -> json or None:
-        try:
-            if self.token is None:
-                raise ValueError("Login first")
-            body = [{"cmd": "SetWifi", "action": 0, "param": {
-                "Wifi": {
-                    "ssid": ssid,
-                    "password": password
-                }}}]
-            param = {"cmd": "SetWifi", "token": self.token}
-            response = Request.post(self.url, data=body, params=param)
-            return json.loads(response.text)
-        except Exception as e:
-            print("Could not Set Wifi details", e)
-            raise
-
-    ###########
-    # GET
-    ###########
-    def get_net_ports(self) -> json or None:
-        """
-        Get network ports
-        :return:
-        """
-        try:
-            if self.token is not None:
-                raise ValueError("Login first")
-
-            body = [{"cmd": "GetNetPort", "action": 1, "param": {}},
-                    {"cmd": "GetUpnp", "action": 0, "param": {}},
-                    {"cmd": "GetP2p", "action": 0, "param": {}}]
-            param = {"token": self.token}
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not get network ports data. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Get Network Ports", e)
-
-    def get_link_local(self):
-        """
-        Get General network data
-        This includes IP address, Device mac, Gateway and DNS
-        :return:
-        """
-        try:
-            if self.token is None:
-                raise ValueError("Login first")
-
-            body = [{"cmd": "GetLocalLink", "action": 1, "param": {}}]
-            param = {"cmd": "GetLocalLink", "token": self.token}
-            request = Request.post(self.url, data=body, params=param)
-            if request.status_code == 200:
-                return json.loads(request.text)
-            print("Could not get ")
-        except Exception as e:
-            print("Could not get Link Local", e)
-            raise
-
-    def get_wifi(self):
-        try:
-            if self.token is None:
-                raise ValueError("Login first")
-            body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
-            param = {"cmd": "GetWifi", "token": self.token}
-            response = Request.post(self.url, data=body, params=param)
-            return json.loads(response.text)
-        except Exception as e:
-            print("Could not get Wifi\n", e)
-            raise
-
-    def scan_wifi(self):
-        try:
-            if self.token is None:
-                raise ValueError("Login first")
-            body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
-            param = {"cmd": "ScanWifi", "token": self.token}
-            response = Request.post(self.url, data=body, params=param)
-            return json.loads(response.text)
-        except Exception as e:
-            print("Could not Scan wifi\n", e)
-            raise
-
-    ###########
-    # Display
-    ###########
-
-    ###########
-    # GET
-    ###########
-    def get_osd(self) -> json or None:
-        """
-        Get OSD information.
-        Response data format is as follows:
-        [{"cmd" : "GetOsd","code" : 0, "initial" : {
-            "Osd" : {"bgcolor" : 0,"channel" : 0,"osdChannel" : {"enable" : 1,"name" : "Camera1","pos" : "Lower Right"},
-            "osdTime" : {"enable" : 1,"pos" : "Top Center"}
-            }},"range" : {"Osd" : {"bgcolor" : "boolean","channel" : 0,"osdChannel" : {"enable" : "boolean","name" : {"maxLen" : 31},
-               "pos" : ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
-            },
-            "osdTime" : {"enable" : "boolean","pos" : ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
-            }
-         }},"value" : {"Osd" : {"bgcolor" : 0,"channel" : 0,"osdChannel" : {"enable" : 0,"name" : "FarRight","pos" : "Lower Right"},
-            "osdTime" : {"enable" : 0,"pos" : "Top Center"}
-         }}}]
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetOsd", "token": self.token}
-            body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve OSD from camera successfully. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get OSD", e)
-            raise
-
-    def get_mask(self) -> json or None:
-        """
-        Get the camera mask information
-        Response data format is as follows:
-        [{"cmd" : "GetMask","code" : 0,"initial" : {
-         "Mask" : {
-            "area" : [{"block" : {"height" : 0,"width" : 0,"x" : 0,"y" : 0},"screen" : {"height" : 0,"width" : 0}}],
-            "channel" : 0,
-            "enable" : 0
-            }
-        },"range" : {"Mask" : {"channel" : 0,"enable" : "boolean","maxAreas" : 4}},"value" : {
-         "Mask" : {
-            "area" : null,
-            "channel" : 0,
-            "enable" : 0}
-        }
-        }]
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetMask", "token": self.token}
-            body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not get Mask from camera successfully. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get mask", e)
-            raise
-
-    ###########
-    # SET
-    ###########
-    def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, osd_channel_name: str = "",
-                osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0,
-                osd_time_pos: str = "Lower Right") -> bool:
-        """
-        Set OSD
-        :param bg_color: bool
-        :param channel: int channel id
-        :param osd_channel_enabled: bool
-        :param osd_channel_name: string channel name
-        :param osd_channel_pos: string channel position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
-        :param osd_time_enabled: bool
-        :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
-        :return:
-        """
-        try:
-            param = {"cmd": "setOsd", "token": self.token}
-            body = [{"cmd": "SetOsd", "action": 1, "param":
-                {"Osd": {"bgcolor": bg_color, "channel": channel,
-                         "osdChannel": {"enable": osd_channel_enabled, "name": osd_channel_name,
-                                        "pos": osd_channel_pos},
-                         "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}
-                         }
-                 }
-                     }]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                r_data = json.loads(response.text)
-                if r_data["value"]["rspCode"] == "200":
-                    return True
-                print("Could not set OSD. Camera responded with status:", r_data["value"])
-                return False
-            print("Could not set OSD. Status:", response.status_code)
-            return False
-        except Exception as e:
-            print("Could not set OSD", e)
-            raise
-
-    ###########
-    # SYSTEM
-    ###########
-
-    ###########
-    # GET
-    ###########
-    def get_general_system(self) -> json or None:
-        try:
-            if self.token is None:
-                raise ValueError("Login first")
-            body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}]
-            param = {"token": self.token}
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve general information from camera successfully. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get General System settings\n", e)
-            raise
-
-    def get_performance(self) -> json or None:
-        """
-        Get a snapshot of the current performance of the camera.
-        Response data format is as follows:
-        [{"cmd" : "GetPerformance",
-        "code" : 0,
-        "value" : {
-        "Performance" : {
-            "codecRate" : 2154,
-            "cpuUsed" : 14,
-            "netThroughput" : 0
-            }
-        }
-        }]
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetPerformance", "token": self.token}
-            body = [{"cmd": "GetPerformance", "action": 0, "param": {}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Cound not retrieve performance information from camera successfully. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get performance", e)
-            raise
-
-    def get_information(self) -> json or None:
-        """
-        Get the camera information
-        Response data format is as follows:
-        [{"cmd" : "GetDevInfo","code" : 0,"value" : {
-         "DevInfo" : {
-            "B485" : 0,
-            "IOInputNum" : 0,
-            "IOOutputNum" : 0,
-            "audioNum" : 0,
-            "buildDay" : "build 18081408",
-            "cfgVer" : "v2.0.0.0",
-            "channelNum" : 1,
-            "detail" : "IPC_3816M100000000100000",
-            "diskNum" : 1,
-            "firmVer" : "v2.0.0.1389_18081408",
-            "hardVer" : "IPC_3816M",
-            "model" : "RLC-411WS",
-            "name" : "Camera1_withpersonality",
-            "serial" : "00000000000000",
-            "type" : "IPC",
-            "wifi" : 1
-            }
-        }
-        }]
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetDevInfo", "token": self.token}
-            body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve camera information. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get device information", e)
-            raise
-
-    ###########
-    # SET
-    ###########
-    def reboot_camera(self) -> bool:
-        """
-        Reboots the camera
-        :return: bool
-        """
-        try:
-            param = {"cmd": "Reboot", "token": self.token}
-            body = [{"cmd": "Reboot", "action": 0, "param": {}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return True
-            print("Something went wrong. Could not reboot camera. Status:", response.status_code)
-            return False
-        except Exception as e:
-            print("Could not reboot camera", e)
-            raise
-
-    ##########
-    # User
-    ##########
-
-    ##########
-    # GET
-    ##########
-    def get_online_user(self) -> json or None:
-        """
-        Return a list of current logged-in users in json format
-        Response data format is as follows:
-        [{"cmd" : "GetOnline","code" : 0,"value" : {
-         "User" : [{
-               "canbeDisconn" : 0,
-               "ip" : "192.168.1.100",
-               "level" : "admin",
-               "sessionId" : 1000,
-               "userName" : "admin"
-               }]
-            }
-        }]
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetOnline", "token": self.token}
-            body = [{"cmd": "GetOnline", "action": 1, "param": {}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve online user from camera. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get online user", e)
-            raise
-
-    def get_users(self) -> json or None:
-        """
-        Return a list of user accounts from the camera in json format
-        Response data format is as follows:
-        [{"cmd" : "GetUser","code" : 0,"initial" : {
-         "User" : {
-            "level" : "guest"
-         }},
-         "range" : {"User" : {
-            "level" : [ "guest", "admin" ],
-            "password" : {
-               "maxLen" : 31,
-               "minLen" : 6
-            },
-            "userName" : {
-               "maxLen" : 31,
-               "minLen" : 1
-            }}
-        },"value" : {
-         "User" : [
-            {
-               "level" : "admin",
-               "userName" : "admin"
-            }]
-        }
-        }]
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetUser", "token": self.token}
-            body = [{"cmd": "GetUser", "action": 1, "param": {}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve users from camera. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get users", e)
-            raise
-
-    ##########
-    # SET
-    ##########
-    def add_user(self, username: str, password: str, level: str = "guest") -> bool:
-        """
-        Add a new user account to the camera
-        :param username: The user's username
-        :param password: The user's password
-        :param level: The privilege level 'guest' or 'admin'. Default is 'guest'
-        :return: bool
-        """
-        try:
-            param = {"cmd": "AddUser", "token": self.token}
-            body = [{"cmd": "AddUser", "action": 0,
-                     "param": {"User": {"userName": username, "password": password, "level": level}}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                r_data = json.loads(response.text)
-                if r_data["value"]["rspCode"] == "200":
-                    return True
-                print("Could not add user. Camera responded with:", r_data["value"])
-                return False
-            print("Something went wrong. Could not add user. Status:", response.status_code)
-            return False
-        except Exception as e:
-            print("Could not add user", e)
-            raise
-
-    def modify_user(self, username: str, password: str) -> bool:
-        """
-        Modify the user's password by specifying their username
-        :param username: The user which would want to be modified
-        :param password: The new password
-        :return: bool
-        """
-        try:
-            param = {"cmd": "ModifyUser", "token": self.token}
-            body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                r_data = json.loads(response.text)
-                if r_data["value"]["rspCode"] == "200":
-                    return True
-                print("Could not modify user:", username, "\nCamera responded with:", r_data["value"])
-            print("Something went wrong. Could not modify user. Status:", response.status_code)
-            return False
-        except Exception as e:
-            print("Could not modify user", e)
-            raise
-
-    def delete_user(self, username: str) -> bool:
-        """
-        Delete a user by specifying their username
-        :param username: The user which would want to be deleted
-        :return: bool
-        """
-        try:
-            param = {"cmd": "DelUser", "token": self.token}
-            body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                r_data = json.loads(response.text)
-                if r_data["value"]["rspCode"] == "200":
-                    return True
-                print("Could not delete user:", username, "\nCamera responded with:", r_data["value"])
-                return False
-        except Exception as e:
-            print("Could not delete user", e)
-            raise
-
-    ##########
-    # Image Data
-    ##########
-    def get_snap(self, timeout: int = 3) -> Image or None:
-        """
-        Gets a "snap" of the current camera video data and returns a Pillow Image or None
-        :param timeout: Request timeout to camera in seconds
-        :return: Image or None
-        """
-        try:
-
-            randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
-            snap = self.url + "?cmd=Snap&channel=0&rs=" \
-                   + randomstr \
-                   + "&user=" + self.username \
-                   + "&password=" + self.password
-            req = request.Request(snap)
-            req.set_proxy(Request.proxies, 'http')
-            reader = request.urlopen(req, timeout)
-            if reader.status == 200:
-                b = bytearray(reader.read())
-                return Image.open(io.BytesIO(b))
-            print("Could not retrieve data from camera successfully. Status:", reader.status)
-            return None
-
-        except Exception as e:
-            print("Could not get Image data\n", e)
-            raise
-
-    #########
-    # Device
-    #########
-    def get_hdd_info(self) -> json or None:
-        """
-        Gets all HDD and SD card information from Camera
-        Response data format is as follows:
-        [{"cmd" : "GetHddInfo",
-        "code" : 0,
-        "value" : {
-        "HddInfo" : [{
-               "capacity" : 15181,
-               "format" : 1,
-               "id" : 0,
-               "mount" : 1,
-               "size" : 15181
-                }]
-            }
-        }]
-
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetHddInfo", "token": self.token}
-            body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve HDD/SD info from camera successfully. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get HDD/SD card information", e)
-            raise
-
-    def format_hdd(self, hdd_id: [int] = [0]) -> bool:
-        """
-        Format specified HDD/SD cards with their id's
-        :param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card)
-        :return: bool
-        """
-        try:
-            param = {"cmd": "Format", "token": self.token}
-            body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                r_data = json.loads(response.text)
-                if r_data["value"]["rspCode"] == "200":
-                    return True
-                print("Could not format HDD/SD. Camera responded with:", r_data["value"])
-                return False
-            print("Could not format HDD/SD. Status:", response.status_code)
-            return False
-        except Exception as e:
-            print("Could not format HDD/SD", e)
-            raise
-
-    ###########
-    # Recording
-    ###########
-
-    ###########
-    # SET
-    ###########
-
-    ###########
-    # GET
-    ###########
-    def get_recording_encoding(self) -> json or None:
-        """
-        Get the current camera encoding settings for "Clear" and "Fluent" profiles.
-        Response data format is as follows:
-        [{
-        "cmd" : "GetEnc",
-        "code" : 0,
-        "initial" : {
-            "Enc" : {
-                "audio" : 0,
-                "channel" : 0,
-                "mainStream" : {
-                "bitRate" : 4096,
-                "frameRate" : 15,
-                "profile" : "High",
-                "size" : "3072*1728"
-                },
-                "subStream" : {
-                    "bitRate" : 160,
-                    "frameRate" : 7,
-                    "profile" : "High",
-                    "size" : "640*360"
-                }
-            }
-        },
-        "range" : {
-            "Enc" : [
-                {
-                "audio" : "boolean",
-                "mainStream" : {
-                    "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ],
-                    "default" : {
-                        "bitRate" : 4096,
-                        "frameRate" : 15
-                    },
-                    "frameRate" : [ 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "3072*1728"
-                },
-                "subStream" : {
-                    "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ],
-                    "default" : {
-                        "bitRate" : 160,
-                        "frameRate" : 7
-                    },
-                    "frameRate" : [ 15, 10, 7, 4 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "640*360"
-                    }
-                },
-                {
-                "audio" : "boolean",
-                "mainStream" : {
-                    "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ],
-                    "default" : {
-                        "bitRate" : 4096,
-                        "frameRate" : 15
-                    },
-                    "frameRate" : [ 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "2592*1944"
-                },
-                "subStream" : {
-                    "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ],
-                    "default" : {
-                        "bitRate" : 160,
-                        "frameRate" : 7
-                    },
-                    "frameRate" : [ 15, 10, 7, 4 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "640*360"
-                }
-            },
-                {
-                "audio" : "boolean",
-                "mainStream" : {
-                    "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ],
-                    "default" : {
-                        "bitRate" : 3072,
-                        "frameRate" : 15
-                    },
-                    "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "2560*1440"
-                },
-                "subStream" : {
-                    "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ],
-                    "default" : {
-                        "bitRate" : 160,
-                        "frameRate" : 7
-                    },
-                    "frameRate" : [ 15, 10, 7, 4 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "640*360"
-                }
-                },
-                {
-                "audio" : "boolean",
-                "mainStream" : {
-                    "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ],
-                    "default" : {
-                        "bitRate" : 3072,
-                        "frameRate" : 15
-                    },
-                    "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "2048*1536"
-                },
-                "subStream" : {
-                    "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ],
-                    "default" : {
-                        "bitRate" : 160,
-                        "frameRate" : 7
-                    },
-                    "frameRate" : [ 15, 10, 7, 4 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "640*360"
-                }
-                },
-            {
-                "audio" : "boolean",
-                "mainStream" : {
-                    "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ],
-                    "default" : {
-                        "bitRate" : 3072,
-                        "frameRate" : 15
-                    },
-                    "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "2304*1296"
-                },
-                "subStream" : {
-                    "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ],
-                    "default" : {
-                        "bitRate" : 160,
-                        "frameRate" : 7
-                    },
-                    "frameRate" : [ 15, 10, 7, 4 ],
-                    "profile" : [ "Base", "Main", "High" ],
-                    "size" : "640*360"
-                }
-                }
-            ]
-        },
-        "value" : {
-            "Enc" : {
-                "audio" : 0,
-                "channel" : 0,
-                "mainStream" : {
-                "bitRate" : 2048,
-                "frameRate" : 20,
-                "profile" : "Main",
-                "size" : "3072*1728"
-                },
-                "subStream" : {
-                "bitRate" : 64,
-                "frameRate" : 4,
-                "profile" : "High",
-                "size" : "640*360"
-                }
-            }
-        }
-        }]
-
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetEnc", "token": self.token}
-            body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve recording encoding data. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get recording encoding", e)
-            raise
-
-    def get_recording_advanced(self) -> json or None:
-        """
-        Get recording advanced setup data
-        Response data format is as follows:
-        [{
-        "cmd" : "GetRec",
-        "code" : 0,
-        "initial" : {
-            "Rec" : {
-                "channel" : 0,
-                "overwrite" : 1,
-                "postRec" : "15 Seconds",
-                "preRec" : 1,
-                "schedule" : {
-                    "enable" : 1,
-                    "table" : "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
-                }
-            }
-        },
-        "range" : {
-            "Rec" : {
-                "channel" : 0,
-                "overwrite" : "boolean",
-                "postRec" : [ "15 Seconds", "30 Seconds", "1 Minute" ],
-                "preRec" : "boolean",
-                "schedule" : {
-                    "enable" : "boolean"
-                }
-            }
-        },
-        "value" : {
-            "Rec" : {
-                "channel" : 0,
-                "overwrite" : 1,
-                "postRec" : "15 Seconds",
-                "preRec" : 1,
-                "schedule" : {
-                    "enable" : 1,
-                    "table" : "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
-                }
-            }
-        }
-        }]
-
-        :return: json or None
-        """
-        try:
-            param = {"cmd": "GetRec", "token": self.token}
-            body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}]
-            response = Request.post(self.url, data=body, params=param)
-            if response.status_code == 200:
-                return json.loads(response.text)
-            print("Could not retrieve advanced recording. Status:", response.status_code)
-            return None
-        except Exception as e:
-            print("Could not get advanced recoding", e)
-
-    ###########
-    # RTSP Stream
-    ###########
-    def open_video_stream(self, profile: str = "main") -> Image:
-        """
-        profile is "main" or "sub"
-        https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player
-        :param profile:
-        :return:
-        """
-        with RtspClient(ip=self.ip, username=self.username, password=self.password,
-                        proxies={"host": "127.0.0.1", "port": 8000}) as rtsp_client:
-            rtsp_client.preview()
-        # with rtsp.Client(
-        #         rtsp_server_uri="rtsp://"
-        #                         + self.username + ":"
-        #                         + self.password + "@"
-        #                         + self.ip
-        #                         + ":554//h264Preview_01_"
-        #                         + profile) as client:
-        #     return client
diff --git a/Camera.py b/Camera.py
deleted file mode 100644
index 1e8a52b..0000000
--- a/Camera.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from APIHandler import APIHandler
-
-
-class Camera(APIHandler):
-
-    def __init__(self, ip, username="admin", password="", https=False):
-        """
-        Initialise the Camera object by passing the ip address.
-        The default details {"username":"admin", "password":""} will be used if nothing passed
-        :param ip:
-        :param username:
-        :param password:
-        """
-        # For when you need to connect to a camera behind a proxy
-        APIHandler.__init__(self, ip, username, password, proxy={"http": "socks5://127.0.0.1:8000"}, https=https)
-
-        # Normal call without proxy:
-        # APIHandler.__init__(self, ip, username, password)
-
-        self.ip = ip
-        self.username = username
-        self.password = password
-        super().login()
diff --git a/ConfigHandler.py b/ConfigHandler.py
deleted file mode 100644
index 67e8d62..0000000
--- a/ConfigHandler.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import io
-
-import yaml
-
-
-class ConfigHandler:
-    camera_settings = {}
-
-    @staticmethod
-    def load() -> yaml or None:
-        try:
-            stream = io.open("config.yml", 'r', encoding='utf8')
-            data = yaml.safe_load(stream)
-            return data
-        except Exception as e:
-            print("Config Property Error\n", e)
-        return None
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..defbd59
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,16 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+
+[packages]
+pillow = "*"
+pyyaml = "*"
+requests = "*"
+numpy = "*"
+opencv-python = "*"
+pysocks = "*"
+
+[requires]
diff --git a/README.md b/README.md
index 7feee35..87a205f 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,123 @@
-## ReolinkCameraAPI
+
 Reolink Python Api Client 
 
-### Purpose
+
+ 
+ 
+ 
+ 
+ 
+
 
-This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS )
+---
 
+A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Cameras,
+although they have a basic API document - it does not satisfy the need for extensive camera communication.
 
-### But Reolink gives an API in their documentation
+Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com)
 
-Not really. They only deliver a really basic API to retrieve Image data and Video data.
 
-### How?
+Other Supported Languages:
+ - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo)
 
-You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network.
+### Join us on Discord
+
+    https://discord.gg/8z3fdAmZJP
+    
+
+### Sponsorship
+
+
+
+[Oleaintueri](https://oleaintueri.com) is sponsoring the development and maintenance of these projects within their organisation.
+
+
+---
 
 ### Get started
 
 Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands.
 
+See the `examples` directory.
+
+### Using the library as a Python Module
+
+Install the package via PyPi
+
+    pip install reolinkapi
+
+Install from GitHub
+
+    pip install git+https://github.com/ReolinkCameraAPI/reolinkapipy.git
+
+If you want to include the video streaming functionality you need to include the streaming "extra" dependencies
+
+    pip install 'reolinkapi[streaming]'
+    
+## Contributors
+
+---
+
 ### Styling and Standards
 
 This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/)
 
+### How can I become a contributor?
+
+#### Step 1
+
+Get the Restful API calls by looking through the HTTP Requests made in the camera's web UI. I use Google Chrome developer mode (ctr + shift + i) -> Network.
+
+#### Step 2
+
+- Fork the repository
+- pip install -r requirements.txt
+- Make your changes
+
+#### Step 3
+
+Make a pull request.
+
 ### API Requests Implementation Plan:
 
+Stream:
+- [X] Blocking RTSP stream
+- [X] Non-Blocking RTSP stream
+
 GET:
 - [X] Login
-- [ ] Display -> OSD
-- [ ] Recording -> Encode (Clear and Fluent Stream)
-- [ ] Recording -> Advance (Scheduling)
+- [X] Logout
+- [X] Display -> OSD
+- [X] Recording -> Encode (Clear and Fluent Stream)
+- [X] Recording -> Advance (Scheduling)
 - [X] Network -> General
 - [X] Network -> Advanced
-- [ ] Network -> DDNS
-- [ ] Network -> NTP
-- [ ] Network -> E-mail
-- [ ] Network -> FTP
-- [ ] Network -> Push
+- [X] Network -> DDNS
+- [X] Network -> NTP
+- [X] Network -> E-mail
+- [X] Network -> FTP
+- [X] Network -> Push
 - [X] Network -> WIFI
-- [ ] Alarm -> Motion
+- [X] Alarm -> Motion
 - [X] System -> General
-- [ ] System -> DST
-- [ ] System -> Information
+- [X] System -> DST
+- [X] System -> Information
 - [ ] System -> Maintenance
-- [ ] System -> Performance
+- [X] System -> Performance
 - [ ] System -> Reboot
-- [ ] User -> Online User
-- [ ] User -> Add User
-- [ ] User -> Manage User
-- [ ] Device -> HDD/SD Card
-- [ ] Zoom
-- [ ] Focus
-- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate)
+- [X] User -> Online User
+- [X] User -> Add User
+- [X] User -> Manage User
+- [X] Device -> HDD/SD Card
+- [x] PTZ -> Presets, Calibration Status
+- [x] Zoom
+- [x] Focus
+- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate)
 - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR)
-- [ ] Image Data -> "Snap" Frame from Video Stream
+- [X] Image Data -> "Snap" Frame from Video Stream
 
 SET:
-- [ ] Display -> OSD
-- [ ] Recording -> Encode (Clear and Fluent Stream)
+- [X] Display -> OSD
+- [X] Recording -> Encode (Clear and Fluent Stream)
 - [ ] Recording -> Advance (Scheduling)
 - [X] Network -> General
 - [X] Network -> Advanced
@@ -66,14 +128,32 @@ SET:
 - [ ] Network -> Push
 - [X] Network -> WIFI
 - [ ] Alarm -> Motion
-- [X] System -> General
+- [ ] System -> General
 - [ ] System -> DST
 - [X] System -> Reboot
-- [ ] User -> Online User
-- [ ] User -> Add User
-- [ ] User -> Manage User
-- [ ] Device -> HDD/SD Card
-- [ ] Zoom
-- [ ] Focus
-- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate)
-- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR)
+- [X] User -> Online User
+- [X] User -> Add User
+- [X] User -> Manage User
+- [X] Device -> HDD/SD Card (Format)
+- [x] PTZ (including calibrate)
+- [x] Zoom
+- [x] Focus
+- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate)
+- [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR)
+
+### Supported Cameras
+
+Any Reolink camera that has a web UI should work. The other's requiring special Reolink clients
+do not work and is not supported here.
+
+- RLC-411WS
+- RLC-423
+- RLC-420-5MP
+- RLC-410-5MP
+- RLC-510A
+- RLC-520
+- RLC-823A
+- C1-Pro
+- D400
+- E1 Zoom
+
diff --git a/RtspClient.py b/RtspClient.py
deleted file mode 100644
index b3cb3fb..0000000
--- a/RtspClient.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import socket
-
-import cv2
-import numpy
-import socks
-
-
-class RtspClient:
-
-    def __init__(self, ip, username, password, port=554, profile="main", **kwargs):
-        """
-
-        :param ip:
-        :param username:
-        :param password:
-        :param port: rtsp port
-        :param profile: "main" or "sub"
-        :param proxies: {"host": "localhost", "port": 8000}
-        """
-        self.ip = ip
-        self.username = username
-        self.password = password
-        self.port = port
-        self.sockt = None
-        self.url = "rtsp://" + self.username + ":" + self.password + "@" + self.ip + ":" + str(
-            self.port) + "//h264Preview_01_" + profile
-        self.proxy = kwargs.get("proxies")
-
-    def __enter__(self):
-        self.sockt = self.connect()
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb):
-        self.sockt.close()
-
-    def connect(self) -> socket:
-        try:
-            sockt = socks.socksocket(socket.AF_INET, socket.SOCK_STREAM)
-            sockt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            if self.proxy is not None:
-                sockt.set_proxy(socks.SOCKS5, self.proxy["host"], self.proxy["port"])
-            sockt.connect((self.ip, self.port))
-            return sockt
-        except Exception as e:
-            print(e)
-
-    def get_frame(self) -> bytearray:
-        try:
-            self.sockt.send(str.encode(self.url))
-            data = b''
-            while True:
-                try:
-                    r = self.sockt.recv(90456)
-                    if len(r) == 0:
-                        break
-                    a = r.find(b'END!')
-                    if a != -1:
-                        data += r[:a]
-                        break
-                    data += r
-                except Exception as e:
-                    print(e)
-                    continue
-            nparr = numpy.fromstring(data, numpy.uint8)
-            frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
-            return frame
-        except Exception as e:
-            print(e)
-
-    def preview(self):
-        """ Blocking function. Opens OpenCV window to display stream. """
-        self.connect()
-        win_name = 'RTSP'
-        cv2.namedWindow(win_name, cv2.WINDOW_AUTOSIZE)
-        cv2.moveWindow(win_name, 20, 20)
-
-        while True:
-            cv2.imshow(win_name, self.get_frame())
-            # if self._latest is not None:
-            #    cv2.imshow(win_name,self._latest)
-            if cv2.waitKey(25) & 0xFF == ord('q'):
-                break
-        cv2.waitKey()
-        cv2.destroyAllWindows()
-        cv2.waitKey()
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
new file mode 100644
index 0000000..1fa5bdd
--- /dev/null
+++ b/examples/basic_usage.py
@@ -0,0 +1,12 @@
+import reolinkapi
+
+if __name__ == "__main__":
+    cam = reolinkapi.Camera("192.168.0.102", defer_login=True)
+
+    # must first login since I defer have deferred the login process
+    cam.login()
+
+    dst = cam.get_dst()
+    ok = cam.add_user("foo", "bar", "admin")
+    alarm = cam.get_alarm_motion()
+    cam.set_device_name(name='my_camera')
\ No newline at end of file
diff --git a/examples/custom_ssl_session.py b/examples/custom_ssl_session.py
new file mode 100644
index 0000000..bd13f5e
--- /dev/null
+++ b/examples/custom_ssl_session.py
@@ -0,0 +1,30 @@
+from reolinkapi import Camera
+
+import urllib3
+import requests
+from urllib3.util import create_urllib3_context
+
+class CustomSSLContextHTTPAdapter(requests.adapters.HTTPAdapter):
+    def __init__(self, ssl_context=None, **kwargs):
+        self.ssl_context = ssl_context
+        super().__init__(**kwargs)
+
+    def init_poolmanager(self, connections, maxsize, block=False):
+        self.poolmanager = urllib3.poolmanager.PoolManager(
+            num_pools=connections, maxsize=maxsize,
+            block=block, ssl_context=self.ssl_context)
+
+urllib3.disable_warnings()
+ctx = create_urllib3_context()
+ctx.load_default_certs()
+ctx.set_ciphers("AES128-GCM-SHA256")
+ctx.check_hostname = False
+
+session = requests.session()
+session.adapters.pop("https://", None)
+session.mount("https://", CustomSSLContextHTTPAdapter(ctx))
+
+## Add a custom http handler to add in different ciphers that may
+## not be aloud by default in openssl which urlib uses
+cam = Camera("url", "user", "password", https=True, session=session)
+cam.reboot_camera()
diff --git a/examples/download_motions.py b/examples/download_motions.py
new file mode 100644
index 0000000..809571f
--- /dev/null
+++ b/examples/download_motions.py
@@ -0,0 +1,54 @@
+"""Downloads all motion events from camera from the past hour."""
+import os
+from configparser import RawConfigParser
+from datetime import datetime as dt, timedelta
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+    """Reads in a properties file into variables.
+
+    NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+    # secrets.cfg
+        [camera]
+        ip={ip_address}
+        username={username}
+        password={password}
+    """
+    config = RawConfigParser()
+    assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+    config.read(props_path)
+    return config
+
+
+# Read in your ip, username, & password
+#   (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure)
+config = read_config('camera.cfg')
+
+ip = config.get('camera', 'ip')
+un = config.get('camera', 'username')
+pw = config.get('camera', 'password')
+
+# Connect to camera
+cam = Camera(ip, un, pw, https=True)
+
+start = dt.combine(dt.now(), dt.min.time())
+end = dt.now()
+# Collect motion events between these timestamps for substream
+processed_motions = cam.get_motion_files(start=start, end=end, streamtype='main', channel=0)
+processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1)
+
+start = dt.now() - timedelta(days=1)
+end = dt.combine(start, dt.max.time())
+processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1)
+
+
+output_files = []
+for i, motion in enumerate(processed_motions):
+    fname = motion['filename']
+    # Download the mp4
+    print("Getting %s" % (fname))
+    output_path = os.path.join('/tmp/', fname.replace('/','_'))
+    output_files += output_path
+    if not os.path.isfile(output_path):
+        resp = cam.get_file(fname, output_path=output_path)
diff --git a/examples/download_playback_video.py b/examples/download_playback_video.py
new file mode 100644
index 0000000..85f8202
--- /dev/null
+++ b/examples/download_playback_video.py
@@ -0,0 +1,46 @@
+"""Downloads a video from camera from start to end time."""
+import os
+from configparser import RawConfigParser
+from datetime import datetime as dt, timedelta
+from reolinkapi import Camera
+import requests
+import pandas as pd
+
+def read_config(props_path: str) -> dict:
+    """Reads in a properties file into variables.
+
+    NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+    # secrets.cfg
+        [camera]
+        ip={ip_address}
+        username={username}
+        password={password}
+    """
+    config = RawConfigParser()
+    assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+    config.read(props_path)
+    return config
+
+
+# Read in your ip, username, & password
+#   (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure)
+config = read_config('camera.cfg')
+
+ip = config.get('camera', 'ip')
+un = config.get('camera', 'username')
+pw = config.get('camera', 'password')
+
+# Connect to camera
+cam = Camera(ip, un, pw)
+
+start = dt.now() - timedelta(minutes=10)
+end = dt.now() - timedelta(minutes=9)
+channel  = 0
+
+files = cam.get_playback_files(start=start, end=end, channel= channel)
+print(files)
+dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads')
+for fname in files:
+    print(fname)
+    # Download the mp4
+    cam.get_file(fname, output_path=os.path.join(dl_dir, fname))
diff --git a/examples/network_config.py b/examples/network_config.py
new file mode 100644
index 0000000..51b1956
--- /dev/null
+++ b/examples/network_config.py
@@ -0,0 +1,62 @@
+import os
+from configparser import RawConfigParser
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+    """Reads in a properties file into variables.
+
+    NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+    # secrets.cfg
+        [camera]
+        ip={ip_address}
+        username={username}
+        password={password}
+    """
+    config = RawConfigParser()
+    assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+    config.read(props_path)
+    return config
+
+
+# Read in your ip, username, & password
+#   (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure)
+config = read_config('camera.cfg')
+
+ip = config.get('camera', 'ip')
+un = config.get('camera', 'username')
+pw = config.get('camera', 'password')
+
+# Connect to camera
+cam = Camera(ip, un, pw)
+
+# Set NTP
+cam.set_ntp(enable=True, interval=1440, port=123, server="time-b.nist.gov")
+
+# Get current network settings
+current_settings = cam.get_network_general()
+print("Current settings:", current_settings)
+
+# Configure DHCP
+cam.set_network_settings(
+    ip="",
+    gateway="",
+    mask="",
+    dns1="",
+    dns2="",
+    mac=current_settings[0]['value']['LocalLink']['mac'],
+    use_dhcp=True,
+    auto_dns=True
+)
+
+# Configure static IP
+# cam.set_network_settings(
+#     ip="192.168.1.102",
+#     gateway="192.168.1.1",
+#     mask="255.255.255.0",
+#     dns1="8.8.8.8",
+#     dns2="8.8.4.4",
+#     mac=current_settings[0]['value']['LocalLink']['mac'],
+#     use_dhcp=False,
+#     auto_dns=False
+# )
\ No newline at end of file
diff --git a/examples/response/GetAlarmMotion.json b/examples/response/GetAlarmMotion.json
new file mode 100644
index 0000000..56be0cc
--- /dev/null
+++ b/examples/response/GetAlarmMotion.json
@@ -0,0 +1,150 @@
+[
+  {
+    "cmd": "GetAlarm",
+    "code": 0,
+    "initial": {
+      "Alarm": {
+        "action": { "mail": 1, "push": 1, "recChannel": [0] },
+        "channel": 0,
+        "enable": 1,
+        "schedule": {
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        },
+        "scope": {
+          "cols": 80,
+          "rows": 45,
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        },
+        "sens": [
+          {
+            "beginHour": 0,
+            "beginMin": 0,
+            "endHour": 6,
+            "endMin": 0,
+            "sensitivity": 10
+          },
+          {
+            "beginHour": 6,
+            "beginMin": 0,
+            "endHour": 12,
+            "endMin": 0,
+            "sensitivity": 10
+          },
+          {
+            "beginHour": 12,
+            "beginMin": 0,
+            "endHour": 18,
+            "endMin": 0,
+            "sensitivity": 10
+          },
+          {
+            "beginHour": 18,
+            "beginMin": 0,
+            "endHour": 23,
+            "endMin": 59,
+            "sensitivity": 10
+          }
+        ],
+        "type": "md"
+      }
+    },
+    "range": {
+      "Alarm": {
+        "action": { "mail": "boolean", "push": "boolean", "recChannel": [0] },
+        "channel": 0,
+        "enable": "boolean",
+        "schedule": { "table": { "maxLen": 168, "minLen": 168 } },
+        "scope": {
+          "cols": { "max": 80, "min": 80 },
+          "rows": { "max": 45, "min": 45 },
+          "table": { "maxLen": 8159 }
+        },
+        "sens": [
+          {
+            "beginHour": { "max": 23, "min": 0 },
+            "beginMin": { "max": 59, "min": 0 },
+            "endHour": { "max": 23, "min": 0 },
+            "endMin": { "max": 59, "min": 0 },
+            "id": 0,
+            "sensitivity": { "max": 50, "min": 1 }
+          },
+          {
+            "beginHour": { "max": 23, "min": 0 },
+            "beginMin": { "max": 59, "min": 0 },
+            "endHour": { "max": 23, "min": 0 },
+            "endMin": { "max": 59, "min": 0 },
+            "id": 1,
+            "sensitivity": { "max": 50, "min": 1 }
+          },
+          {
+            "beginHour": { "max": 23, "min": 0 },
+            "beginMin": { "max": 59, "min": 0 },
+            "endHour": { "max": 23, "min": 0 },
+            "endMin": { "max": 59, "min": 0 },
+            "id": 2,
+            "sensitivity": { "max": 50, "min": 1 }
+          },
+          {
+            "beginHour": { "max": 23, "min": 0 },
+            "beginMin": { "max": 59, "min": 0 },
+            "endHour": { "max": 23, "min": 0 },
+            "endMin": { "max": 59, "min": 0 },
+            "id": 3,
+            "sensitivity": { "max": 50, "min": 1 }
+          }
+        ],
+        "type": "md"
+      }
+    },
+    "value": {
+      "Alarm": {
+        "action": { "mail": 1, "push": 1, "recChannel": [0] },
+        "channel": 0,
+        "enable": 1,
+        "schedule": {
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        },
+        "scope": {
+          "cols": 80,
+          "rows": 45,
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        },
+        "sens": [
+          {
+            "beginHour": 0,
+            "beginMin": 0,
+            "endHour": 6,
+            "endMin": 0,
+            "id": 0,
+            "sensitivity": 10
+          },
+          {
+            "beginHour": 6,
+            "beginMin": 0,
+            "endHour": 12,
+            "endMin": 0,
+            "id": 1,
+            "sensitivity": 10
+          },
+          {
+            "beginHour": 12,
+            "beginMin": 0,
+            "endHour": 18,
+            "endMin": 0,
+            "id": 2,
+            "sensitivity": 10
+          },
+          {
+            "beginHour": 18,
+            "beginMin": 0,
+            "endHour": 23,
+            "endMin": 59,
+            "id": 3,
+            "sensitivity": 10
+          }
+        ],
+        "type": "md"
+      }
+    }
+  }
+]
diff --git a/examples/response/GetDSTInfo.json b/examples/response/GetDSTInfo.json
new file mode 100644
index 0000000..5895d9e
--- /dev/null
+++ b/examples/response/GetDSTInfo.json
@@ -0,0 +1,35 @@
+[
+  {
+    "cmd": "GetTime",
+    "code": 0,
+    "value": {
+      "Dst": {
+        "enable": 1,
+        "endHour": 2,
+        "endMin": 0,
+        "endMon": 11,
+        "endSec": 0,
+        "endWeek": 1,
+        "endWeekday": 0,
+        "offset": 1,
+        "startHour": 2,
+        "startMin": 0,
+        "startMon": 3,
+        "startSec": 0,
+        "startWeek": 1,
+        "startWeekday": 0
+      },
+      "Time": {
+        "day": 27,
+        "hour": 18,
+        "hourFmt": 0,
+        "min": 50,
+        "mon": 10,
+        "sec": 46,
+        "timeFmt": "MM/DD/YYYY",
+        "timeZone": 21600,
+        "year": 2020
+      }
+    }
+  }
+]
diff --git a/examples/response/GetDevInfo.json b/examples/response/GetDevInfo.json
new file mode 100644
index 0000000..9018183
--- /dev/null
+++ b/examples/response/GetDevInfo.json
@@ -0,0 +1,26 @@
+[
+  {
+    "cmd": "GetDevInfo",
+    "code": 0,
+    "value": {
+      "DevInfo": {
+        "B485": 0,
+        "IOInputNum": 0,
+        "IOOutputNum": 0,
+        "audioNum": 0,
+        "buildDay": "build 18081408",
+        "cfgVer": "v2.0.0.0",
+        "channelNum": 1,
+        "detail": "IPC_3816M100000000100000",
+        "diskNum": 1,
+        "firmVer": "v2.0.0.1389_18081408",
+        "hardVer": "IPC_3816M",
+        "model": "RLC-411WS",
+        "name": "Camera1_withpersonality",
+        "serial": "00000000000000",
+        "type": "IPC",
+        "wifi": 1
+      }
+    }
+  }
+]
diff --git a/examples/response/GetEnc.json b/examples/response/GetEnc.json
new file mode 100644
index 0000000..547ed6f
--- /dev/null
+++ b/examples/response/GetEnc.json
@@ -0,0 +1,377 @@
+[
+  {
+    "cmd": "GetEnc",
+    "code": 0,
+    "initial": {
+      "Enc": {
+        "audio": 0,
+        "channel": 0,
+        "mainStream": {
+          "bitRate": 4096,
+          "frameRate": 15,
+          "profile": "High",
+          "size": "3072*1728"
+        },
+        "subStream": {
+          "bitRate": 160,
+          "frameRate": 7,
+          "profile": "High",
+          "size": "640*360"
+        }
+      }
+    },
+    "range": {
+      "Enc": [
+        {
+          "audio": "boolean",
+          "mainStream": {
+            "bitRate": [
+              1024,
+              1536,
+              2048,
+              3072,
+              4096,
+              5120,
+              6144,
+              7168,
+              8192
+            ],
+            "default": {
+              "bitRate": 4096,
+              "frameRate": 15
+            },
+            "frameRate": [
+              20,
+              18,
+              16,
+              15,
+              12,
+              10,
+              8,
+              6,
+              4,
+              2
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "3072*1728"
+          },
+          "subStream": {
+            "bitRate": [
+              64,
+              128,
+              160,
+              192,
+              256,
+              384,
+              512
+            ],
+            "default": {
+              "bitRate": 160,
+              "frameRate": 7
+            },
+            "frameRate": [
+              15,
+              10,
+              7,
+              4
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "640*360"
+          }
+        },
+        {
+          "audio": "boolean",
+          "mainStream": {
+            "bitRate": [
+              1024,
+              1536,
+              2048,
+              3072,
+              4096,
+              5120,
+              6144,
+              7168,
+              8192
+            ],
+            "default": {
+              "bitRate": 4096,
+              "frameRate": 15
+            },
+            "frameRate": [
+              20,
+              18,
+              16,
+              15,
+              12,
+              10,
+              8,
+              6,
+              4,
+              2
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "2592*1944"
+          },
+          "subStream": {
+            "bitRate": [
+              64,
+              128,
+              160,
+              192,
+              256,
+              384,
+              512
+            ],
+            "default": {
+              "bitRate": 160,
+              "frameRate": 7
+            },
+            "frameRate": [
+              15,
+              10,
+              7,
+              4
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "640*360"
+          }
+        },
+        {
+          "audio": "boolean",
+          "mainStream": {
+            "bitRate": [
+              1024,
+              1536,
+              2048,
+              3072,
+              4096,
+              5120,
+              6144,
+              7168,
+              8192
+            ],
+            "default": {
+              "bitRate": 3072,
+              "frameRate": 15
+            },
+            "frameRate": [
+              30,
+              22,
+              20,
+              18,
+              16,
+              15,
+              12,
+              10,
+              8,
+              6,
+              4,
+              2
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "2560*1440"
+          },
+          "subStream": {
+            "bitRate": [
+              64,
+              128,
+              160,
+              192,
+              256,
+              384,
+              512
+            ],
+            "default": {
+              "bitRate": 160,
+              "frameRate": 7
+            },
+            "frameRate": [
+              15,
+              10,
+              7,
+              4
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "640*360"
+          }
+        },
+        {
+          "audio": "boolean",
+          "mainStream": {
+            "bitRate": [
+              1024,
+              1536,
+              2048,
+              3072,
+              4096,
+              5120,
+              6144,
+              7168,
+              8192
+            ],
+            "default": {
+              "bitRate": 3072,
+              "frameRate": 15
+            },
+            "frameRate": [
+              30,
+              22,
+              20,
+              18,
+              16,
+              15,
+              12,
+              10,
+              8,
+              6,
+              4,
+              2
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "2048*1536"
+          },
+          "subStream": {
+            "bitRate": [
+              64,
+              128,
+              160,
+              192,
+              256,
+              384,
+              512
+            ],
+            "default": {
+              "bitRate": 160,
+              "frameRate": 7
+            },
+            "frameRate": [
+              15,
+              10,
+              7,
+              4
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "640*360"
+          }
+        },
+        {
+          "audio": "boolean",
+          "mainStream": {
+            "bitRate": [
+              1024,
+              1536,
+              2048,
+              3072,
+              4096,
+              5120,
+              6144,
+              7168,
+              8192
+            ],
+            "default": {
+              "bitRate": 3072,
+              "frameRate": 15
+            },
+            "frameRate": [
+              30,
+              22,
+              20,
+              18,
+              16,
+              15,
+              12,
+              10,
+              8,
+              6,
+              4,
+              2
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "2304*1296"
+          },
+          "subStream": {
+            "bitRate": [
+              64,
+              128,
+              160,
+              192,
+              256,
+              384,
+              512
+            ],
+            "default": {
+              "bitRate": 160,
+              "frameRate": 7
+            },
+            "frameRate": [
+              15,
+              10,
+              7,
+              4
+            ],
+            "profile": [
+              "Base",
+              "Main",
+              "High"
+            ],
+            "size": "640*360"
+          }
+        }
+      ]
+    },
+    "value": {
+      "Enc": {
+        "audio": 0,
+        "channel": 0,
+        "mainStream": {
+          "bitRate": 2048,
+          "frameRate": 20,
+          "profile": "Main",
+          "size": "3072*1728"
+        },
+        "subStream": {
+          "bitRate": 64,
+          "frameRate": 4,
+          "profile": "High",
+          "size": "640*360"
+        }
+      }
+    }
+  }
+]
diff --git a/examples/response/GetGeneralSystem.json b/examples/response/GetGeneralSystem.json
new file mode 100644
index 0000000..814671e
--- /dev/null
+++ b/examples/response/GetGeneralSystem.json
@@ -0,0 +1,100 @@
+[
+  {
+    "cmd": "GetTime",
+    "code": 0,
+    "initial": {
+      "Dst": {
+        "enable": 0,
+        "endHour": 2,
+        "endMin": 0,
+        "endMon": 10,
+        "endSec": 0,
+        "endWeek": 5,
+        "endWeekday": 0,
+        "offset": 1,
+        "startHour": 2,
+        "startMin": 0,
+        "startMon": 3,
+        "startSec": 0,
+        "startWeek": 2,
+        "startWeekday": 0
+      },
+      "Time": {
+        "day": 1,
+        "hour": 0,
+        "hourFmt": 0,
+        "min": 0,
+        "mon": 0,
+        "sec": 0,
+        "timeFmt": "DD/MM/YYYY",
+        "timeZone": 28800,
+        "year": 0
+      }
+    },
+    "range": {
+      "Dst": {
+        "enable": "boolean",
+        "endHour": { "max": 23, "min": 0 },
+        "endMin": { "max": 59, "min": 0 },
+        "endMon": { "max": 12, "min": 1 },
+        "endSec": { "max": 59, "min": 0 },
+        "endWeek": { "max": 5, "min": 1 },
+        "endWeekday": { "max": 6, "min": 0 },
+        "offset": { "max": 2, "min": 1 },
+        "startHour": { "max": 23, "min": 0 },
+        "startMin": { "max": 59, "min": 0 },
+        "startMon": { "max": 12, "min": 1 },
+        "startSec": { "max": 59, "min": 0 },
+        "startWeek": { "max": 5, "min": 1 },
+        "startWeekday": { "max": 6, "min": 0 }
+      },
+      "Time": {
+        "day": { "max": 31, "min": 1 },
+        "hour": { "max": 23, "min": 0 },
+        "hourFmt": { "max": 1, "min": 0 },
+        "min": { "max": 59, "min": 0 },
+        "mon": { "max": 12, "min": 1 },
+        "sec": { "max": 59, "min": 0 },
+        "timeFmt": ["MM/DD/YYYY", "YYYY/MM/DD", "DD/MM/YYYY"],
+        "timeZone": { "max": 43200, "min": -46800 },
+        "year": { "max": 2100, "min": 1900 }
+      }
+    },
+    "value": {
+      "Dst": {
+        "enable": 1,
+        "endHour": 2,
+        "endMin": 0,
+        "endMon": 11,
+        "endSec": 0,
+        "endWeek": 1,
+        "endWeekday": 0,
+        "offset": 1,
+        "startHour": 2,
+        "startMin": 0,
+        "startMon": 3,
+        "startSec": 0,
+        "startWeek": 1,
+        "startWeekday": 0
+      },
+      "Time": {
+        "day": 9,
+        "hour": 15,
+        "hourFmt": 0,
+        "min": 33,
+        "mon": 12,
+        "sec": 58,
+        "timeFmt": "MM/DD/YYYY",
+        "timeZone": 21600,
+        "year": 2020
+      }
+    }
+  },
+  {
+    "cmd": "GetNorm",
+    "code": 0,
+    "initial": { "norm": "NTSC" },
+    "range": { "norm": ["PAL", "NTSC"] },
+    "value": { "norm": "NTSC" }
+  }
+]
diff --git a/examples/response/GetHddInfo.json b/examples/response/GetHddInfo.json
new file mode 100644
index 0000000..d95b7aa
--- /dev/null
+++ b/examples/response/GetHddInfo.json
@@ -0,0 +1,17 @@
+[
+  {
+    "cmd": "GetHddInfo",
+    "code": 0,
+    "value": {
+      "HddInfo": [
+        {
+          "capacity": 15181,
+          "format": 1,
+          "id": 0,
+          "mount": 1,
+          "size": 15181
+        }
+      ]
+    }
+  }
+]
diff --git a/examples/response/GetMask.json b/examples/response/GetMask.json
new file mode 100644
index 0000000..beb345a
--- /dev/null
+++ b/examples/response/GetMask.json
@@ -0,0 +1,40 @@
+[
+  {
+    "cmd": "GetMask",
+    "code": 0,
+    "initial": {
+      "Mask": {
+        "area": [
+          {
+            "block": {
+              "height": 0,
+              "width": 0,
+              "x": 0,
+              "y": 0
+            },
+            "screen": {
+              "height": 0,
+              "width": 0
+            }
+          }
+        ],
+        "channel": 0,
+        "enable": 0
+      }
+    },
+    "range": {
+      "Mask": {
+        "channel": 0,
+        "enable": "boolean",
+        "maxAreas": 4
+      }
+    },
+    "value": {
+      "Mask": {
+        "area": null,
+        "channel": 0,
+        "enable": 0
+      }
+    }
+  }
+]
diff --git a/examples/response/GetNetworkAdvanced.json b/examples/response/GetNetworkAdvanced.json
new file mode 100644
index 0000000..25c729e
--- /dev/null
+++ b/examples/response/GetNetworkAdvanced.json
@@ -0,0 +1,42 @@
+[
+  {
+    "cmd": "GetNetPort",
+    "code": 0,
+    "initial": {
+      "NetPort": {
+        "httpPort": 80,
+        "httpsPort": 443,
+        "mediaPort": 9000,
+        "onvifPort": 8000,
+        "rtmpPort": 1935,
+        "rtspPort": 554
+      }
+    },
+    "range": {
+      "NetPort": {
+        "httpPort": { "max": 65535, "min": 1 },
+        "httpsPort": { "max": 65535, "min": 1 },
+        "mediaPort": { "max": 65535, "min": 1 },
+        "onvifPort": { "max": 65535, "min": 1 },
+        "rtmpPort": { "max": 65535, "min": 1 },
+        "rtspPort": { "max": 65535, "min": 1 }
+      }
+    },
+    "value": {
+      "NetPort": {
+        "httpPort": 80,
+        "httpsPort": 443,
+        "mediaPort": 9000,
+        "onvifPort": 8000,
+        "rtmpPort": 1935,
+        "rtspPort": 554
+      }
+    }
+  },
+  { "cmd": "GetUpnp", "code": 0, "value": { "Upnp": { "enable": 0 } } },
+  {
+    "cmd": "GetP2p",
+    "code": 0,
+    "value": { "P2p": { "enable": 0, "uid": "99999999999999" } }
+  }
+]
diff --git a/examples/response/GetNetworkDDNS.json b/examples/response/GetNetworkDDNS.json
new file mode 100644
index 0000000..a79127a
--- /dev/null
+++ b/examples/response/GetNetworkDDNS.json
@@ -0,0 +1,15 @@
+[
+  {
+    "cmd": "GetDdns",
+    "code": 0,
+    "value": {
+      "Ddns": {
+        "domain": "",
+        "enable": 0,
+        "password": "",
+        "type": "no-ip",
+        "userName": ""
+      }
+    }
+  }
+]
diff --git a/examples/response/GetNetworkEmail.json b/examples/response/GetNetworkEmail.json
new file mode 100644
index 0000000..9595a2a
--- /dev/null
+++ b/examples/response/GetNetworkEmail.json
@@ -0,0 +1,25 @@
+[
+  {
+    "cmd": "GetEmail",
+    "code": 0,
+    "value": {
+      "Email": {
+        "addr1": "",
+        "addr2": "",
+        "addr3": "",
+        "attachment": "picture",
+        "interval": "5 Minutes",
+        "nickName": "",
+        "password": "",
+        "schedule": {
+          "enable": 1,
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        },
+        "smtpPort": 465,
+        "smtpServer": "smtp.gmail.com",
+        "ssl": 1,
+        "userName": ""
+      }
+    }
+  }
+]
diff --git a/examples/response/GetNetworkFtp.json b/examples/response/GetNetworkFtp.json
new file mode 100644
index 0000000..8ba4622
--- /dev/null
+++ b/examples/response/GetNetworkFtp.json
@@ -0,0 +1,24 @@
+[
+  {
+    "cmd": "GetFtp",
+    "code": 0,
+    "value": {
+      "Ftp": {
+        "anonymous": 0,
+        "interval": 30,
+        "maxSize": 100,
+        "mode": 0,
+        "password": "",
+        "port": 21,
+        "remoteDir": "",
+        "schedule": {
+          "enable": 1,
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        },
+        "server": "",
+        "streamType": 0,
+        "userName": ""
+      }
+    }
+  }
+]
diff --git a/examples/response/GetNetworkGeneral.json b/examples/response/GetNetworkGeneral.json
new file mode 100644
index 0000000..958bbdd
--- /dev/null
+++ b/examples/response/GetNetworkGeneral.json
@@ -0,0 +1,19 @@
+[
+  {
+    "cmd": "GetLocalLink",
+    "code": 0,
+    "value": {
+      "LocalLink": {
+        "activeLink": "LAN",
+        "dns": { "auto": 1, "dns1": "192.168.255.4", "dns2": "192.168.255.4" },
+        "mac": "EC:71:DB:AA:59:CF",
+        "static": {
+          "gateway": "192.168.255.1",
+          "ip": "192.168.255.58",
+          "mask": "255.255.255.0"
+        },
+        "type": "DHCP"
+      }
+    }
+  }
+]
diff --git a/examples/response/GetNetworkNTP.json b/examples/response/GetNetworkNTP.json
new file mode 100644
index 0000000..7293fed
--- /dev/null
+++ b/examples/response/GetNetworkNTP.json
@@ -0,0 +1,14 @@
+[
+  {
+    "cmd": "GetNtp",
+    "code": 0,
+    "value": {
+      "Ntp": {
+        "enable": 1,
+        "interval": 1440,
+        "port": 123,
+        "server": "ntp.moos.xyz"
+      }
+    }
+  }
+]
diff --git a/examples/response/GetNetworkPush.json b/examples/response/GetNetworkPush.json
new file mode 100644
index 0000000..399207f
--- /dev/null
+++ b/examples/response/GetNetworkPush.json
@@ -0,0 +1,14 @@
+[
+  {
+    "cmd": "GetPush",
+    "code": 0,
+    "value": {
+      "Push": {
+        "schedule": {
+          "enable": 1,
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        }
+      }
+    }
+  }
+]
diff --git a/examples/response/GetOnline.json b/examples/response/GetOnline.json
new file mode 100644
index 0000000..34f59d1
--- /dev/null
+++ b/examples/response/GetOnline.json
@@ -0,0 +1,17 @@
+[
+  {
+    "cmd": "GetOnline",
+    "code": 0,
+    "value": {
+      "User": [
+        {
+          "canbeDisconn": 0,
+          "ip": "192.168.1.100",
+          "level": "admin",
+          "sessionId": 1000,
+          "userName": "admin"
+        }
+      ]
+    }
+  }
+]
diff --git a/examples/response/GetOsd.json b/examples/response/GetOsd.json
new file mode 100644
index 0000000..e925b90
--- /dev/null
+++ b/examples/response/GetOsd.json
@@ -0,0 +1,67 @@
+[
+  {
+    "cmd": "GetOsd",
+    "code": 0,
+    "initial": {
+      "Osd": {
+        "bgcolor": 0,
+        "channel": 0,
+        "osdChannel": {
+          "enable": 1,
+          "name": "Camera1",
+          "pos": "Lower Right"
+        },
+        "osdTime": {
+          "enable": 1,
+          "pos": "Top Center"
+        }
+      }
+    },
+    "range": {
+      "Osd": {
+        "bgcolor": "boolean",
+        "channel": 0,
+        "osdChannel": {
+          "enable": "boolean",
+          "name": {
+            "maxLen": 31
+          },
+          "pos": [
+            "Upper Left",
+            "Top Center",
+            "Upper Right",
+            "Lower Left",
+            "Bottom Center",
+            "Lower Right"
+          ]
+        },
+        "osdTime": {
+          "enable": "boolean",
+          "pos": [
+            "Upper Left",
+            "Top Center",
+            "Upper Right",
+            "Lower Left",
+            "Bottom Center",
+            "Lower Right"
+          ]
+        }
+      }
+    },
+    "value": {
+      "Osd": {
+        "bgcolor": 0,
+        "channel": 0,
+        "osdChannel": {
+          "enable": 0,
+          "name": "FarRight",
+          "pos": "Lower Right"
+        },
+        "osdTime": {
+          "enable": 0,
+          "pos": "Top Center"
+        }
+      }
+    }
+  }
+]
\ No newline at end of file
diff --git a/examples/response/GetPerformance.json b/examples/response/GetPerformance.json
new file mode 100644
index 0000000..73bc9ba
--- /dev/null
+++ b/examples/response/GetPerformance.json
@@ -0,0 +1,13 @@
+[
+  {
+    "cmd": "GetPerformance",
+    "code": 0,
+    "value": {
+      "Performance": {
+        "codecRate": 2154,
+        "cpuUsed": 14,
+        "netThroughput": 0
+      }
+    }
+  }
+]
diff --git a/examples/response/GetPtzCheckState.json b/examples/response/GetPtzCheckState.json
new file mode 100644
index 0000000..55386ec
--- /dev/null
+++ b/examples/response/GetPtzCheckState.json
@@ -0,0 +1,19 @@
+[
+  {
+    "cmd": "GetPtzCheckState",
+    "code": 0,
+    "initial": {
+      "PtzCheckState": 2
+    },
+    "range": {
+      "PtzCheckState": [
+        0,
+        1,
+        2
+      ]
+    },
+    "value": {
+      "PtzCheckState": 2
+    }
+  }
+]
diff --git a/examples/response/GetPtzPresets.json b/examples/response/GetPtzPresets.json
new file mode 100644
index 0000000..b89dad3
--- /dev/null
+++ b/examples/response/GetPtzPresets.json
@@ -0,0 +1,926 @@
+[
+  {
+    "cmd": "GetPtzPreset",
+    "code": 0,
+    "initial": {
+      "PtzPreset": [
+        {
+          "channel": 0,
+          "enable": 1,
+          "id": 0,
+          "imgName": "preset_00",
+          "name": "namepos0"
+        },
+        {
+          "channel": 0,
+          "enable": 1,
+          "id": 1,
+          "imgName": "preset_01",
+          "name": "othernamepos1"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 2,
+          "imgName": "",
+          "name": "pos3"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 3,
+          "imgName": "",
+          "name": "pos4"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 4,
+          "imgName": "",
+          "name": "pos5"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 5,
+          "imgName": "",
+          "name": "pos6"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 6,
+          "imgName": "",
+          "name": "pos7"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 7,
+          "imgName": "",
+          "name": "pos8"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 8,
+          "imgName": "",
+          "name": "pos9"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 9,
+          "imgName": "",
+          "name": "pos10"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 10,
+          "imgName": "",
+          "name": "pos11"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 11,
+          "imgName": "",
+          "name": "pos12"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 12,
+          "imgName": "",
+          "name": "pos13"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 13,
+          "imgName": "",
+          "name": "pos14"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 14,
+          "imgName": "",
+          "name": "pos15"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 15,
+          "imgName": "",
+          "name": "pos16"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 16,
+          "imgName": "",
+          "name": "pos17"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 17,
+          "imgName": "",
+          "name": "pos18"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 18,
+          "imgName": "",
+          "name": "pos19"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 19,
+          "imgName": "",
+          "name": "pos20"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 20,
+          "imgName": "",
+          "name": "pos21"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 21,
+          "imgName": "",
+          "name": "pos22"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 22,
+          "imgName": "",
+          "name": "pos23"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 23,
+          "imgName": "",
+          "name": "pos24"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 24,
+          "imgName": "",
+          "name": "pos25"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 25,
+          "imgName": "",
+          "name": "pos26"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 26,
+          "imgName": "",
+          "name": "pos27"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 27,
+          "imgName": "",
+          "name": "pos28"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 28,
+          "imgName": "",
+          "name": "pos29"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 29,
+          "imgName": "",
+          "name": "pos30"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 30,
+          "imgName": "",
+          "name": "pos31"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 31,
+          "imgName": "",
+          "name": "pos32"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 32,
+          "imgName": "",
+          "name": "pos33"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 33,
+          "imgName": "",
+          "name": "pos34"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 34,
+          "imgName": "",
+          "name": "pos35"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 35,
+          "imgName": "",
+          "name": "pos36"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 36,
+          "imgName": "",
+          "name": "pos37"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 37,
+          "imgName": "",
+          "name": "pos38"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 38,
+          "imgName": "",
+          "name": "pos39"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 39,
+          "imgName": "",
+          "name": "pos40"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 40,
+          "imgName": "",
+          "name": "pos41"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 41,
+          "imgName": "",
+          "name": "pos42"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 42,
+          "imgName": "",
+          "name": "pos43"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 43,
+          "imgName": "",
+          "name": "pos44"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 44,
+          "imgName": "",
+          "name": "pos45"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 45,
+          "imgName": "",
+          "name": "pos46"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 46,
+          "imgName": "",
+          "name": "pos47"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 47,
+          "imgName": "",
+          "name": "pos48"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 48,
+          "imgName": "",
+          "name": "pos49"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 49,
+          "imgName": "",
+          "name": "pos50"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 50,
+          "imgName": "",
+          "name": "pos51"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 51,
+          "imgName": "",
+          "name": "pos52"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 52,
+          "imgName": "",
+          "name": "pos53"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 53,
+          "imgName": "",
+          "name": "pos54"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 54,
+          "imgName": "",
+          "name": "pos55"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 55,
+          "imgName": "",
+          "name": "pos56"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 56,
+          "imgName": "",
+          "name": "pos57"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 57,
+          "imgName": "",
+          "name": "pos58"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 58,
+          "imgName": "",
+          "name": "pos59"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 59,
+          "imgName": "",
+          "name": "pos60"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 60,
+          "imgName": "",
+          "name": "pos61"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 61,
+          "imgName": "",
+          "name": "pos62"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 62,
+          "imgName": "",
+          "name": "pos63"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 63,
+          "imgName": "",
+          "name": "pos64"
+        }
+      ]
+    },
+    "range": {
+      "PtzPreset": {
+        "channel": 0,
+        "enable": "boolean",
+        "id": {
+          "max": 64,
+          "min": 1
+        },
+        "imgName": {
+          "maxLen": 31
+        },
+        "name": {
+          "maxLen": 31
+        }
+      }
+    },
+    "value": {
+      "PtzPreset": [
+        {
+          "channel": 0,
+          "enable": 1,
+          "id": 0,
+          "imgName": "preset_00",
+          "name": "0"
+        },
+        {
+          "channel": 0,
+          "enable": 1,
+          "id": 1,
+          "imgName": "preset_01",
+          "name": "1"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 2,
+          "imgName": "",
+          "name": "pos3"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 3,
+          "imgName": "",
+          "name": "pos4"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 4,
+          "imgName": "",
+          "name": "pos5"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 5,
+          "imgName": "",
+          "name": "pos6"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 6,
+          "imgName": "",
+          "name": "pos7"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 7,
+          "imgName": "",
+          "name": "pos8"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 8,
+          "imgName": "",
+          "name": "pos9"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 9,
+          "imgName": "",
+          "name": "pos10"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 10,
+          "imgName": "",
+          "name": "pos11"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 11,
+          "imgName": "",
+          "name": "pos12"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 12,
+          "imgName": "",
+          "name": "pos13"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 13,
+          "imgName": "",
+          "name": "pos14"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 14,
+          "imgName": "",
+          "name": "pos15"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 15,
+          "imgName": "",
+          "name": "pos16"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 16,
+          "imgName": "",
+          "name": "pos17"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 17,
+          "imgName": "",
+          "name": "pos18"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 18,
+          "imgName": "",
+          "name": "pos19"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 19,
+          "imgName": "",
+          "name": "pos20"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 20,
+          "imgName": "",
+          "name": "pos21"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 21,
+          "imgName": "",
+          "name": "pos22"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 22,
+          "imgName": "",
+          "name": "pos23"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 23,
+          "imgName": "",
+          "name": "pos24"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 24,
+          "imgName": "",
+          "name": "pos25"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 25,
+          "imgName": "",
+          "name": "pos26"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 26,
+          "imgName": "",
+          "name": "pos27"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 27,
+          "imgName": "",
+          "name": "pos28"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 28,
+          "imgName": "",
+          "name": "pos29"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 29,
+          "imgName": "",
+          "name": "pos30"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 30,
+          "imgName": "",
+          "name": "pos31"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 31,
+          "imgName": "",
+          "name": "pos32"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 32,
+          "imgName": "",
+          "name": "pos33"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 33,
+          "imgName": "",
+          "name": "pos34"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 34,
+          "imgName": "",
+          "name": "pos35"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 35,
+          "imgName": "",
+          "name": "pos36"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 36,
+          "imgName": "",
+          "name": "pos37"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 37,
+          "imgName": "",
+          "name": "pos38"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 38,
+          "imgName": "",
+          "name": "pos39"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 39,
+          "imgName": "",
+          "name": "pos40"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 40,
+          "imgName": "",
+          "name": "pos41"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 41,
+          "imgName": "",
+          "name": "pos42"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 42,
+          "imgName": "",
+          "name": "pos43"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 43,
+          "imgName": "",
+          "name": "pos44"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 44,
+          "imgName": "",
+          "name": "pos45"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 45,
+          "imgName": "",
+          "name": "pos46"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 46,
+          "imgName": "",
+          "name": "pos47"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 47,
+          "imgName": "",
+          "name": "pos48"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 48,
+          "imgName": "",
+          "name": "pos49"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 49,
+          "imgName": "",
+          "name": "pos50"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 50,
+          "imgName": "",
+          "name": "pos51"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 51,
+          "imgName": "",
+          "name": "pos52"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 52,
+          "imgName": "",
+          "name": "pos53"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 53,
+          "imgName": "",
+          "name": "pos54"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 54,
+          "imgName": "",
+          "name": "pos55"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 55,
+          "imgName": "",
+          "name": "pos56"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 56,
+          "imgName": "",
+          "name": "pos57"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 57,
+          "imgName": "",
+          "name": "pos58"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 58,
+          "imgName": "",
+          "name": "pos59"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 59,
+          "imgName": "",
+          "name": "pos60"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 60,
+          "imgName": "",
+          "name": "pos61"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 61,
+          "imgName": "",
+          "name": "pos62"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 62,
+          "imgName": "",
+          "name": "pos63"
+        },
+        {
+          "channel": 0,
+          "enable": 0,
+          "id": 63,
+          "imgName": "",
+          "name": "pos64"
+        }
+      ]
+    }
+  }
+]
diff --git a/examples/response/GetRec.json b/examples/response/GetRec.json
new file mode 100644
index 0000000..623c353
--- /dev/null
+++ b/examples/response/GetRec.json
@@ -0,0 +1,45 @@
+[
+  {
+    "cmd": "GetRec",
+    "code": 0,
+    "initial": {
+      "Rec": {
+        "channel": 0,
+        "overwrite": 1,
+        "postRec": "15 Seconds",
+        "preRec": 1,
+        "schedule": {
+          "enable": 1,
+          "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+        }
+      }
+    },
+    "range": {
+      "Rec": {
+        "channel": 0,
+        "overwrite": "boolean",
+        "postRec": [
+          "15 Seconds",
+          "30 Seconds",
+          "1 Minute"
+        ],
+        "preRec": "boolean",
+        "schedule": {
+          "enable": "boolean"
+        }
+      }
+    },
+    "value": {
+      "Rec": {
+        "channel": 0,
+        "overwrite": 1,
+        "postRec": "15 Seconds",
+        "preRec": 1,
+        "schedule": {
+          "enable": 1,
+          "table": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+        }
+      }
+    }
+  }
+]
diff --git a/examples/response/GetUser.json b/examples/response/GetUser.json
new file mode 100644
index 0000000..d3d77e7
--- /dev/null
+++ b/examples/response/GetUser.json
@@ -0,0 +1,35 @@
+[
+  {
+    "cmd": "GetUser",
+    "code": 0,
+    "initial": {
+      "User": {
+        "level": "guest"
+      }
+    },
+    "range": {
+      "User": {
+        "level": [
+          "guest",
+          "admin"
+        ],
+        "password": {
+          "maxLen": 31,
+          "minLen": 6
+        },
+        "userName": {
+          "maxLen": 31,
+          "minLen": 1
+        }
+      }
+    },
+    "value": {
+      "User": [
+        {
+          "level": "admin",
+          "userName": "admin"
+        }
+      ]
+    }
+  }
+]
diff --git a/examples/response/Login.json b/examples/response/Login.json
new file mode 100644
index 0000000..5ce4120
--- /dev/null
+++ b/examples/response/Login.json
@@ -0,0 +1,7 @@
+[
+  {
+    "cmd": "Login",
+    "code": 0,
+    "value": { "Token": { "leaseTime": 3600, "name": "xxxxxxxxxxxx" } }
+  }
+]
diff --git a/examples/response/Logout.json b/examples/response/Logout.json
new file mode 100644
index 0000000..a0588b2
--- /dev/null
+++ b/examples/response/Logout.json
@@ -0,0 +1,7 @@
+[
+  {
+    "cmd": "Logout",
+    "code": 0,
+    "value": { "rspCode": 200 }
+  }
+]
diff --git a/examples/response/PtzCheck.json b/examples/response/PtzCheck.json
new file mode 100644
index 0000000..8274769
--- /dev/null
+++ b/examples/response/PtzCheck.json
@@ -0,0 +1 @@
+[{"cmd": "PtzCheck", "code": 0, "value": {"rspCode": 200}}]
\ No newline at end of file
diff --git a/examples/response/PtzCtrl.json b/examples/response/PtzCtrl.json
new file mode 100644
index 0000000..fa8e1fb
--- /dev/null
+++ b/examples/response/PtzCtrl.json
@@ -0,0 +1,9 @@
+[
+    {
+       "cmd" : "PtzCtrl",
+       "code" : 0,
+       "value" : {
+          "rspCode" : 200
+       }
+    }
+ ]
\ No newline at end of file
diff --git a/examples/response/Reboot.json b/examples/response/Reboot.json
new file mode 100644
index 0000000..eb10195
--- /dev/null
+++ b/examples/response/Reboot.json
@@ -0,0 +1,9 @@
+[
+    {
+        "cmd": "Reboot",
+        "code": 0,
+        "value": {
+            "rspCode": 200
+        }
+    }
+]
diff --git a/examples/response/SetAdvImageSettings.json b/examples/response/SetAdvImageSettings.json
new file mode 100644
index 0000000..780adf3
--- /dev/null
+++ b/examples/response/SetAdvImageSettings.json
@@ -0,0 +1,10 @@
+[
+    {
+       "cmd" : "SetIsp",
+       "code" : 0,
+       "value" : {
+          "rspCode" : 200
+       }
+    }
+ ]
+ 
\ No newline at end of file
diff --git a/examples/response/SetImageSettings.json b/examples/response/SetImageSettings.json
new file mode 100644
index 0000000..26ef93d
--- /dev/null
+++ b/examples/response/SetImageSettings.json
@@ -0,0 +1,10 @@
+[
+    {
+       "cmd" : "SetImage",
+       "code" : 0,
+       "value" : {
+          "rspCode" : 200
+       }
+    }
+ ]
+ 
\ No newline at end of file
diff --git a/examples/response/SetPtzPreset.json b/examples/response/SetPtzPreset.json
new file mode 100644
index 0000000..84121bc
--- /dev/null
+++ b/examples/response/SetPtzPreset.json
@@ -0,0 +1,10 @@
+[
+    {
+       "cmd" : "SetPtzPreset",
+       "code" : 0,
+       "value" : {
+          "rspCode" : 200
+       }
+    }
+ ]
+ 
\ No newline at end of file
diff --git a/examples/stream_gui.py b/examples/stream_gui.py
new file mode 100644
index 0000000..c174bf9
--- /dev/null
+++ b/examples/stream_gui.py
@@ -0,0 +1,175 @@
+import sys
+import os
+from configparser import RawConfigParser
+from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QSlider
+from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
+from PyQt6.QtMultimediaWidgets import QVideoWidget
+from PyQt6.QtCore import Qt, QUrl, QTimer
+from PyQt6.QtGui import QWheelEvent
+from reolinkapi import Camera
+from threading import Lock
+
+import urllib3
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+def read_config(props_path: str) -> dict:
+    config = RawConfigParser()
+    assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+    config.read(props_path)
+    return config
+
+class ZoomSlider(QSlider):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def keyPressEvent(self, event):
+        if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down):
+            event.ignore()
+        else:
+            super().keyPressEvent(event)
+
+class CameraPlayer(QWidget):
+    def __init__(self, rtsp_url_wide, rtsp_url_telephoto, camera: Camera):
+        super().__init__()
+        self.setWindowTitle("Reolink PTZ Streamer")
+        self.setGeometry(10, 10, 2400, 900)
+        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 
+
+        self.camera = camera
+        self.zoom_timer = QTimer(self)
+        self.zoom_timer.timeout.connect(self.stop_zoom)
+
+        # Create media players
+        self.media_player_wide = QMediaPlayer()
+        self.media_player_telephoto = QMediaPlayer()
+
+        # Create video widgets
+        self.video_widget_wide = QVideoWidget()
+        self.video_widget_telephoto = QVideoWidget()
+        self.media_player_wide.setVideoOutput(self.video_widget_wide)
+        self.media_player_telephoto.setVideoOutput(self.video_widget_telephoto)
+        self.video_widget_wide.wheelEvent = self.handle_wheel_event
+        self.video_widget_telephoto.wheelEvent = self.handle_wheel_event
+
+        # Create layout
+        layout = QHBoxLayout()
+        layout.addWidget(self.video_widget_wide, 2)
+        layout.addWidget(self.video_widget_telephoto, 2)
+        self.setLayout(layout)
+
+        # Start playing the streams
+        self.media_player_wide.setSource(QUrl(rtsp_url_wide))
+        self.media_player_telephoto.setSource(QUrl(rtsp_url_telephoto))
+        self.media_player_wide.play()
+        self.media_player_telephoto.play()
+
+
+    def keyPressEvent(self, event):
+        if event.isAutoRepeat():
+            return
+        if event.key() == Qt.Key.Key_Escape:
+            self.close()
+        elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down):
+            self.start_move(event.key())
+
+    def keyReleaseEvent(self, event):
+        if event.isAutoRepeat():
+            return
+        if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down):
+            self.stop_move()
+
+    def start_move(self, key):
+        direction = {
+            Qt.Key.Key_Left: "left",
+            Qt.Key.Key_Right: "right",
+            Qt.Key.Key_Up: "up",
+            Qt.Key.Key_Down: "down"
+        }.get(key)
+
+        if direction:
+            self.move_camera(direction)
+
+    def stop_move(self):
+        response = self.camera.stop_ptz()
+        print("Stop PTZ")
+        if response[0].get('code') != 0:
+            self.show_error_message("Failed to stop camera movement", str(response[0]))
+
+    def move_camera(self, direction):
+        speed = 25
+        if direction == "left":
+            response = self.camera.move_left(speed)
+        elif direction == "right":
+            response = self.camera.move_right(speed)
+        elif direction == "up":
+            response = self.camera.move_up(speed)
+        elif direction == "down":
+            response = self.camera.move_down(speed)
+        else:
+            print(f"Invalid direction: {direction}")
+            return
+
+        if response[0].get('code') == 0:
+            print(f"Moving camera {direction}")
+        else:
+            self.show_error_message(f"Failed to move camera {direction}", str(response[0]))
+
+    def handle_wheel_event(self, event: QWheelEvent):
+        delta = event.angleDelta().y()
+        if delta > 0:
+            self.zoom_in()
+        elif delta < 0:
+            self.zoom_out()
+
+    def zoom_in(self):
+        self.start_zoom('in')
+
+    def zoom_out(self):
+        self.start_zoom('out')
+
+    def start_zoom(self, direction: str):
+        self.zoom_timer.stop()  # Stop any ongoing zoom timer
+        speed = 60  # You can adjust this value as needed
+        if direction == 'in':
+            response = self.camera.start_zooming_in(speed)
+        else:
+            response = self.camera.start_zooming_out(speed)
+        
+        if response[0].get('code') == 0:
+            print(f"Zooming {direction}")
+            self.zoom_timer.start(200)  # Stop zooming after 200ms
+        else:
+            self.show_error_message(f"Failed to start zooming {direction}", str(response[0]))
+
+    def stop_zoom(self):
+        response = self.camera.stop_zooming()
+        if response[0].get('code') != 0:
+            self.show_error_message("Failed to stop zooming", str(response[0]))
+
+    def show_error_message(self, title, message):
+        print(f"Error: {title} {message}")
+
+    def handle_error(self, error):
+        print(f"Media player error: {error}")
+
+
+if __name__ == '__main__':
+    # Read in your ip, username, & password from the configuration file
+    config = read_config('camera.cfg')
+    ip = config.get('camera', 'ip')
+    un = config.get('camera', 'username')
+    pw = config.get('camera', 'password')
+
+    # Connect to camera
+    cam = Camera(ip, un, pw, https=True)
+
+    rtsp_url_wide =      f"rtsp://{un}:{pw}@{ip}/Preview_01_sub"
+    rtsp_url_telephoto = f"rtsp://{un}:{pw}@{ip}/Preview_02_sub"
+
+    # Connect to camera
+    cam = Camera(ip, un, pw, https=True)
+
+    app = QApplication(sys.argv)
+    player = CameraPlayer(rtsp_url_wide, rtsp_url_telephoto, cam)
+    player.show()
+    sys.exit(app.exec())
diff --git a/examples/streaming_video.py b/examples/streaming_video.py
new file mode 100644
index 0000000..9049ed8
--- /dev/null
+++ b/examples/streaming_video.py
@@ -0,0 +1,81 @@
+import cv2
+from reolinkapi import Camera
+
+
+def non_blocking():
+    print("calling non-blocking")
+
+    def inner_callback(img):
+        cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600))
+        print("got the image non-blocking")
+        key = cv2.waitKey(1)
+        if key == ord('q'):
+            cv2.destroyAllWindows()
+            exit(1)
+
+    c = Camera("192.168.1.112", "admin", "jUa2kUzi")
+    # t in this case is a thread
+    t = c.open_video_stream(callback=inner_callback)
+
+    print(t.is_alive())
+    while True:
+        if not t.is_alive():
+            print("continuing")
+            break
+        # stop the stream
+        # client.stop_stream()
+
+
+def blocking():
+    c = Camera("192.168.1.112", "admin", "jUa2kUzi")
+    # stream in this case is a generator returning an image (in mat format)
+    stream = c.open_video_stream()
+
+    # using next()
+    # while True:
+    #     img = next(stream)
+    #     cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600))
+    #     print("got the image blocking")
+    #     key = cv2.waitKey(1)
+    #     if key == ord('q'):
+    #         cv2.destroyAllWindows()
+    #         exit(1)
+
+    # or using a for loop
+    for img in stream:
+        cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600))
+        print("got the image blocking")
+        key = cv2.waitKey(1)
+        if key == ord('q'):
+            cv2.destroyAllWindows()
+            exit(1)
+
+
+# Resizes a image and maintains aspect ratio
+def maintain_aspect_ratio_resize(image, width=None, height=None, inter=cv2.INTER_AREA):
+    # Grab the image size and initialize dimensions
+    dim = None
+    (h, w) = image.shape[:2]
+
+    # Return original image if no need to resize
+    if width is None and height is None:
+        return image
+
+    # We are resizing height if width is none
+    if width is None:
+        # Calculate the ratio of the height and construct the dimensions
+        r = height / float(h)
+        dim = (int(w * r), height)
+    # We are resizing width if height is none
+    else:
+        # Calculate the ratio of the 0idth and construct the dimensions
+        r = width / float(w)
+        dim = (width, int(h * r))
+
+    # Return the resized image
+    return cv2.resize(image, dim, interpolation=inter)
+
+
+# Call the methods. Either Blocking (using generator) or Non-Blocking using threads
+# non_blocking()
+blocking()
diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py
new file mode 100644
index 0000000..6327dca
--- /dev/null
+++ b/examples/video_review_gui.py
@@ -0,0 +1,657 @@
+# Video review GUI
+# https://github.com/sven337/ReolinkLinux/wiki#reolink-video-review-gui
+
+import os
+import signal
+import sys
+import re
+import datetime
+import subprocess
+import argparse
+from configparser import RawConfigParser
+from datetime import datetime as dt, timedelta
+from reolinkapi import Camera
+from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QFileDialog, QHeaderView, QStyle, QSlider, QStyleOptionSlider, QSplitter, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator
+from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
+from PyQt6.QtMultimediaWidgets import QVideoWidget
+from PyQt6.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QMutex, QWaitCondition
+from PyQt6.QtGui import QColor, QBrush, QFont, QIcon
+from collections import deque
+
+import urllib3
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+def path_name_from_camera_path(fname):
+   # Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4
+   return fname.replace('/', '_')
+
+# Function to decode hex values into individual flags
+def decode_hex_to_flags(hex_value):
+    flags_mapping = {
+        'resolution_index': (21, 7), 
+        'tv_system': (20, 1),        
+        'framerate': (13, 7),        
+        'audio_index': (11, 2),      
+        'ai_pd': (10, 1),          # person  
+        'ai_fd': (9, 1),              # face
+        'ai_vd': (8, 1),             # vehicle
+        'ai_ad': (7, 1),             # animal
+        'encoder_type_index': (5, 2),
+        'is_schedule_record': (4, 1), #scheduled
+        'is_motion_record': (3, 1),  # motion detected
+        'is_rf_record': (2, 1),      
+        'is_doorbell_press_record': (1, 1),
+        'ai_other': (0, 1)               
+    }
+    hex_value = int(hex_value, 16)  # Convert hex string to integer
+    flag_values = {}
+    for flag, (bit_position, bit_size) in flags_mapping.items():
+        mask = ((1 << bit_size) - 1) << bit_position
+        flag_values[flag] = (hex_value & mask) >> bit_position
+    return flag_values
+
+def parse_filename(file_name):
+    #  Mp4Record_2024-08-12_RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4
+    #  Mp4Record_2024-09-13-RecS09_DST20240907_084519_084612_0_55289080000000_307BC0.mp4
+    # https://github.com/sven337/ReolinkLinux/wiki/Figuring-out-the-file-names#file-name-structure
+    pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_Rec[MS](\d)(\d)_(DST)?(\d{8})_(\d{6})_(\d{6})'
+    v3_suffix = r'.*_(\w{4,8})_(\w{4,8})\.mp4'
+    v9_suffix = r'.*_(\d)_(\w{7})(\w{7})_(\w{1,8})\.mp4'
+    match = re.match(pattern, file_name)
+
+    out = {}
+    version = 0
+
+    if match:
+        date = match.group(1)  # YYYY-MM-DD
+        channel = int(match.group(2))  
+        version = int(match.group(3)) # version
+        start_date = match.group(5)  # YYYYMMDD
+        start_time = match.group(6)  # HHMMSS
+        end_time = match.group(7)  # HHMMSS
+
+        # Combine date and start time into a datetime object
+        start_datetime = datetime.datetime.strptime(f"{start_date} {start_time}", "%Y%m%d %H%M%S")
+        
+        out = {'start_datetime': start_datetime, 'channel': channel, 'end_time': end_time }
+    else:
+        print("parse error")
+        return None
+       
+    if version == 9:
+        match = re.match(v9_suffix, file_name)
+        if not match:
+            print(f"v9 parse error for {file_name}")
+            return None
+
+        animal_type = match.group(1)
+        flags_hex1 = match.group(2)
+        flags_hex2 = match.group(3)
+        file_size = int(match.group(4), 16)
+
+        triggers = decode_hex_to_flags(flags_hex1)
+
+        out.update({'animal_type' : animal_type, 'file_size' : file_size, 'triggers' : triggers })
+
+    elif version == 2 or version == 3:
+        match = re.match(v3_suffix, file_name)
+        if not match:
+            print(f"v3 parse error for {file_name}")
+            return None
+
+        flags_hex = match.group(1)
+        file_size = int(match.group(2), 16)
+
+        triggers = decode_hex_to_flags(flags_hex)
+
+        out.update({'file_size' : file_size, 'triggers' : triggers })
+
+    return out
+
+class ClickableSlider(QSlider):
+    def mousePressEvent(self, event):
+        if event.button() == Qt.MouseButton.LeftButton:
+            val = self.pixelPosToRangeValue(event.pos())
+            self.setValue(val)
+        super().mousePressEvent(event)
+
+    def pixelPosToRangeValue(self, pos):
+        opt = QStyleOptionSlider()
+        self.initStyleOption(opt)
+        gr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self)
+        sr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self)
+
+        if self.orientation() == Qt.Orientation.Horizontal:
+            sliderLength = sr.width()
+            sliderMin = gr.x()
+            sliderMax = gr.right() - sliderLength + 1
+            pos = pos.x()
+        else:
+            sliderLength = sr.height()
+            sliderMin = gr.y()
+            sliderMax = gr.bottom() - sliderLength + 1
+            pos = pos.y()
+
+        return QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos - sliderMin, sliderMax - sliderMin, opt.upsideDown)
+
+class DownloadThread(QThread):
+    download_complete = pyqtSignal(str, bool)
+    download_start = pyqtSignal(str)
+
+    def __init__(self, download_queue, cam):
+        super().__init__()
+        self.download_queue = download_queue
+        self.cam = cam
+        self.mutex = QMutex()
+        self.wait_condition = QWaitCondition()
+        self.is_running = True
+
+    def run(self):
+        while self.is_running:
+            self.mutex.lock()
+            if len(self.download_queue) == 0:
+                self.wait_condition.wait(self.mutex)
+            self.mutex.unlock()
+
+            if not self.is_running:
+                break
+
+            try:
+                fname, output_path = self.download_queue.popleft()
+                output_path = os.path.join(video_storage_dir, output_path)
+                print(f"Downloading: {fname}")
+                self.download_start.emit(output_path)
+                resp = self.cam.get_file(fname, output_path=output_path)
+                if resp:
+                    print(f"Download complete: {output_path}")
+                    self.download_complete.emit(output_path, True)
+                else:
+                    print(f"Download failed: {fname}")
+                    self.download_complete.emit(output_path, False)
+            except IndexError:
+                pass
+
+    def stop(self):
+        self.mutex.lock()
+        self.is_running = False
+        self.mutex.unlock()
+        self.wait_condition.wakeAll()
+
+    def add_to_queue(self, fname, output_path, left=False):
+        self.mutex.lock()
+        if left:
+            self.download_queue.appendleft((fname, output_path))
+        else:
+            self.download_queue.append((fname, output_path))
+        self.wait_condition.wakeOne()
+        self.mutex.unlock()
+
+
+class VideoPlayer(QWidget):
+    file_exists_signal = pyqtSignal(str, bool)
+
+    def __init__(self, video_files):
+        super().__init__()
+        self.setWindowTitle("Reolink Video Review GUI")
+        self.cam = cam
+        self.download_queue = deque()
+        self.download_thread = DownloadThread(self.download_queue, self.cam)
+        self.download_thread.download_start.connect(self.on_download_start)
+        self.download_thread.download_complete.connect(self.on_download_complete)
+        self.download_thread.start()
+
+        # Create media player
+        self.media_player = QMediaPlayer()
+        self.media_player.errorOccurred.connect(self.handle_media_player_error)
+
+        # Create video widget
+        self.video_widget = QVideoWidget()
+        self.media_player.setVideoOutput(self.video_widget)
+        self.media_player.setPlaybackRate(1.5)
+        
+        # Create table widget to display video files
+        self.video_tree = QTreeWidget()
+        self.video_tree.setColumnCount(10)
+        self.video_tree.setHeaderLabels(["Status", "Video Path", "Start Datetime", "End Time", "Channel", "Person", "Vehicle", "Pet", "Motion", "Timer"])
+        self.video_tree.setSortingEnabled(True)
+        self.video_tree.itemClicked.connect(self.play_video)
+
+        # Set smaller default column widths
+        self.video_tree.setColumnWidth(0, 35)   # Status
+        self.video_tree.setColumnWidth(1, 120)  # Video Path
+        self.video_tree.setColumnWidth(2, 130)  # Start Datetime
+        self.video_tree.setColumnWidth(3, 70)   # End Time
+        self.video_tree.setColumnWidth(4, 35)   # Channel
+        self.video_tree.setColumnWidth(5, 35)   # Person
+        self.video_tree.setColumnWidth(6, 35)   # Vehicle
+        self.video_tree.setColumnWidth(7, 35)   # Pet
+        self.video_tree.setColumnWidth(8, 35)   # Motion
+        self.video_tree.setColumnWidth(9, 30)   # Timer
+
+        self.video_tree.setIndentation(10)
+
+        QIcon.setThemeName("Adwaita")
+
+        # Create open button to select video files
+        self.open_button = QPushButton("Open Videos")
+        self.open_button.clicked.connect(self.open_videos)
+        
+        self.play_button = QPushButton()
+        self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
+        self.play_button.clicked.connect(self.play_pause)
+
+        self.mpv_button = QPushButton("MPV")
+        self.mpv_button.clicked.connect(self.open_in_mpv)
+       
+        self.get_highres_button = QPushButton("GetHighRes")
+        self.get_highres_button.clicked.connect(self.get_highres_stream_for_file)
+        self.get_highres_button.setEnabled(False)  # Disable by default
+
+        # Create seek slider
+        self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal)
+        self.seek_slider.setRange(0, 0)
+        self.seek_slider.sliderPressed.connect(self.seek_slider_pressed)
+        self.seek_slider.sliderReleased.connect(self.seek_slider_released)
+        self.seek_slider.sliderMoved.connect(self.seek_slider_moved)
+        self.media_player.positionChanged.connect(self.update_position)
+        self.media_player.durationChanged.connect(self.update_duration)
+        
+        # Create playback speed slider
+        self.speed_slider = QSlider(Qt.Orientation.Horizontal)
+        self.speed_slider.setRange(50, 300)
+        self.speed_slider.setValue(200)
+        self.speed_slider.valueChanged.connect(self.set_speed)
+        speed_label = QLabel("Speed: 2.0x")
+        self.speed_slider.valueChanged.connect(lambda v: speed_label.setText(f"Speed: {v/100:.1f}x"))
+        
+        main_layout = QHBoxLayout()
+        # Create a splitter
+        splitter = QSplitter(Qt.Orientation.Horizontal)
+        
+        # Left side (table and open button)
+        left_widget = QWidget()
+        left_layout = QVBoxLayout(left_widget)
+        left_layout.addWidget(self.video_tree)
+        left_layout.addWidget(self.open_button)
+        splitter.addWidget(left_widget)
+        
+        # Right side (video player and controls)
+        right_widget = QWidget()
+        right_layout = QVBoxLayout(right_widget)
+        right_layout.addWidget(self.video_widget, 1)
+
+        controls_widget = QWidget()
+        controls_layout = QVBoxLayout(controls_widget)
+
+        control_layout = QHBoxLayout()
+        control_layout.addWidget(self.play_button)
+        control_layout.addWidget(self.seek_slider)
+        control_layout.addWidget(self.mpv_button)
+        control_layout.addWidget(self.get_highres_button)
+        controls_layout.addLayout(control_layout)
+
+        speed_layout = QHBoxLayout()
+        speed_layout.addWidget(QLabel("Speed:"))
+        speed_layout.addWidget(self.speed_slider)
+        speed_layout.addWidget(speed_label)
+        controls_layout.addLayout(speed_layout)
+        right_layout.addWidget(controls_widget)
+        splitter.addWidget(right_widget)
+        
+        # Set initial sizes
+        splitter.setSizes([300, 700]) 
+        
+        main_layout.addWidget(splitter)
+        self.setLayout(main_layout)
+        self.file_exists_signal.connect(self.on_download_complete)
+
+        self.add_initial_videos(video_files)
+
+    def add_initial_videos(self, video_files):
+        for video_path in video_files:
+            self.add_video(video_path)
+        self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder)
+#        self.video_tree.expandAll()
+
+    def open_videos(self):
+        file_dialog = QFileDialog(self)
+        file_dialog.setNameFilters(["Videos (*.mp4 *.avi *.mov)"])
+        file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) 
+        if file_dialog.exec():
+            self.video_tree.setSortingEnabled(False)
+            for file in file_dialog.selectedFiles():
+               self.add_video(os.path.basename(file))
+            self.video_tree.setSortingEnabled(True)
+        self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder)
+#        self.video_tree.expandAll()
+
+    def add_video(self, video_path):
+        # We are passed the camera file name, e.g. Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4
+        file_path = path_name_from_camera_path(video_path)  
+        base_file_name = file_path
+        parsed_data = parse_filename(file_path)
+        if not parsed_data:
+            print(f"Could not parse file {video_path}")
+            return
+
+        start_datetime = parsed_data['start_datetime']
+        channel = parsed_data['channel']
+
+        end_time = datetime.datetime.strptime(parsed_data['end_time'], "%H%M%S")
+        end_time_str = end_time.strftime("%H:%M:%S")
+
+        video_item = QTreeWidgetItem()
+        video_item.setText(0, "") # Status
+        video_item.setTextAlignment(0, Qt.AlignmentFlag.AlignCenter)
+        video_item.setText(1, base_file_name)
+        video_item.setText(2, start_datetime.strftime("%Y-%m-%d %H:%M:%S"))
+        video_item.setData(2, Qt.ItemDataRole.UserRole, parsed_data['start_datetime'])
+        video_item.setText(3, end_time_str)
+        video_item.setText(4, str(channel))
+        video_item.setText(5, "✓" if parsed_data['triggers']['ai_pd'] else "")
+        video_item.setText(6, "✓" if parsed_data['triggers']['ai_vd'] else "")
+        video_item.setText(7, "✓" if parsed_data['triggers']['ai_ad'] else "")
+        video_item.setText(8, "✓" if parsed_data['triggers']['is_motion_record'] else "")
+        video_item.setText(9, "✓" if parsed_data['triggers']['is_schedule_record'] else "")
+
+        if parsed_data['triggers']['ai_other']:
+            print(f"File {file_path} has ai_other flag!")
+        
+        video_item.setToolTip(1, base_file_name)
+        # Set the style for queued status
+        grey_color = QColor(200, 200, 200)  # Light grey
+        video_item.setForeground(1, QBrush(grey_color))
+        font = QFont()
+        font.setItalic(True)
+        video_item.setFont(1, font)
+
+        # Make the fields non-editable
+        iterator = QTreeWidgetItemIterator(self.video_tree)
+        while iterator.value():
+            item = iterator.value()
+            item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+            iterator += 1
+
+        # Find a potentially pre-existing channel0 item for this datetime, if so, add as a child
+        # This lets channel1 appear as a child, but also main & sub videos appear in the same group
+        channel_0_item = self.find_channel_0_item(start_datetime)
+        
+        if channel_0_item:
+            # Check if the current item is a main stream and the existing channel_0_item is a sub stream
+            if "RecM" in base_file_name and "RecS" in channel_0_item.text(1):
+                # Make the current main stream item the new parent
+                new_parent = video_item
+                # Move all children of the sub stream item to the new main stream item
+                while channel_0_item.childCount() > 0:
+                    child = channel_0_item.takeChild(0)
+                    new_parent.addChild(child)
+                # Remove the old sub stream item
+                parent = channel_0_item.parent()
+                if parent:
+                    parent.removeChild(channel_0_item)
+                else:
+                    index = self.video_tree.indexOfTopLevelItem(channel_0_item)
+                    self.video_tree.takeTopLevelItem(index)
+                # Add the new main stream item as a top-level item
+                self.video_tree.addTopLevelItem(new_parent)
+            else:
+                # If it's not a main stream replacing a sub stream, add as a child as before
+                channel_0_item.addChild(video_item)
+        else:
+            self.video_tree.addTopLevelItem(video_item)
+
+        output_path = os.path.join(video_storage_dir, base_file_name)
+        expected_size = parsed_data['file_size']
+
+        need_download = True
+        if os.path.isfile(output_path):
+            actual_size = os.path.getsize(output_path)
+            if actual_size == expected_size:
+                need_download = False
+                self.file_exists_signal.emit(output_path, True)
+            else:
+                print(f"File size mismatch for {output_path}. Expected: {expected_size}, Actual: {actual_size}. Downloading again")
+        
+        if need_download:
+            video_item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_CommandLink))
+            self.download_thread.add_to_queue(video_path, base_file_name)
+
+    def find_channel_0_item(self, datetime_obj):
+        # Truncate seconds to nearest 10
+        truncated_seconds = datetime_obj.second - (datetime_obj.second % 10)
+        truncated_datetime = datetime_obj.replace(second=truncated_seconds)
+        
+        for i in range(self.video_tree.topLevelItemCount()):
+            item = self.video_tree.topLevelItem(i)
+            item_datetime = item.data(2, Qt.ItemDataRole.UserRole)
+            item_truncated = item_datetime.replace(second=item_datetime.second - (item_datetime.second % 10))
+            if item_truncated == truncated_datetime:
+                return item
+        return None
+
+    def find_item_by_path(self, path):
+        iterator = QTreeWidgetItemIterator(self.video_tree)
+        while iterator.value():
+            item = iterator.value()
+            text = item.text(1)
+            if text == path:
+                return item
+            iterator += 1
+        print(f"Could not find item by path {path}")
+        return None
+
+    def on_download_complete(self, video_path, success):
+        item = self.find_item_by_path(os.path.basename(video_path))
+        if not item:
+            print(f"on_download_complete {video_path} did not find item?!")
+            return
+        item.setForeground(1, QBrush(QColor(0, 0, 0)))  # Black color for normal text
+        font = item.font(1)
+        font.setItalic(False)
+        font.setBold(False)
+        item.setFont(1, font)
+        if success:
+            if "RecS" in item.text(1):
+                # One day (hopefully) offer the option to download the
+                # high-res version This is not trivial because we have to
+                # re-do a camera search for the relevant time period and
+                # match based on start and end dates (+/- one second in my
+                # experience)
+                # For now simply display that this is low-res.
+                item.setText(0, "sub")
+            item.setIcon(1, QIcon())
+        else:
+            item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical))
+
+
+    def on_download_start(self, video_path):
+        item = self.find_item_by_path(os.path.basename(video_path))
+        if item:
+            grey_color = QColor(200, 200, 200)  # Light grey
+            item.setForeground(1, QBrush(grey_color))
+            font = item.font(1)
+            font.setBold(True)
+            item.setFont(1, font)
+            item.setIcon(1, QIcon.fromTheme("emblem-synchronizing"))
+        else:
+            print(f"Cannot find item for {video_path}")
+
+    def play_video(self, file_name_item, column):
+        video_path = os.path.join(video_storage_dir, file_name_item.text(1))
+       
+        if file_name_item.font(1).italic() or file_name_item.foreground(1).color().lightness() >= 200:
+            print(f"Video {video_path} is not yet downloaded. Moving it to top of queue. Please wait for download.")
+             # Find the item in the download_queue that matches the base file name
+            found_item = None
+            for item in list(self.download_queue):
+                if item[1] == file_name_item.text(1):
+                    found_item = item
+                    break
+
+            if found_item:
+                # Remove the item from its current position in the queue
+                self.download_queue.remove(found_item)
+                # Add the item to the end of the queue
+                self.download_thread.add_to_queue(*found_item, left=True)
+            return
+
+        # Enable/disable GetHighRes button based on whether it's a sub stream
+        self.get_highres_button.setEnabled("RecS" in file_name_item.text(1))
+
+        print(f"Playing video: {video_path}")
+        url = QUrl.fromLocalFile(video_path)
+        self.media_player.setSource(url)
+        self.video_widget.show()
+        def start_playback():
+                # Seek to 5 seconds (pre-record)
+                self.media_player.setPosition(5000)
+                self.media_player.play()
+                self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
+                print(f"Media player state: {self.media_player.playbackState()}")
+
+        # Timer needed to be able to play at seek offset in the video, otherwise setPosition seems ignored
+        QTimer.singleShot(20, start_playback)
+
+    def get_highres_stream_for_file(self):
+        current_item = self.video_tree.currentItem()
+        if not current_item or "RecS" not in current_item.text(1):
+            return
+
+        parsed_data = parse_filename(current_item.text(1))
+        if not parsed_data:
+            print(f"Could not parse file {current_item.text(1)}")
+            return
+
+        start_time = parsed_data['start_datetime'] - timedelta(seconds=1)
+        end_time = datetime.datetime.strptime(f"{parsed_data['start_datetime'].strftime('%Y%m%d')} {parsed_data['end_time']}", "%Y%m%d %H%M%S") + timedelta(seconds=1)
+
+        main_files = self.cam.get_motion_files(start=start_time, end=end_time, streamtype='main', channel=parsed_data['channel'])
+
+        if main_files:
+            for main_file in main_files:
+                self.add_video(main_file['filename'])
+            self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder)
+        else:
+            print(f"No main stream file found for {current_item.text(1)}")
+
+    def play_pause(self):
+        if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
+            self.media_player.pause()
+            self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
+        else:
+            self.media_player.play()
+            self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
+
+    def handle_media_player_error(self, error):
+        print(f"Media player error: {error}")
+
+    def seek_slider_pressed(self):
+        self.media_player.setPosition(self.seek_slider.value())
+        self.media_player.play()
+
+    def seek_slider_released(self):
+        self.media_player.setPosition(self.seek_slider.value())
+        self.media_player.play()
+
+    def seek_slider_moved(self, position):
+        # Update video frame while dragging
+        self.media_player.setPosition(position)
+
+    def update_position(self, position):
+            self.seek_slider.setValue(position)
+
+    def update_duration(self, duration):
+        self.seek_slider.setRange(0, duration)
+
+    def set_speed(self, speed):
+        self.media_player.setPlaybackRate(speed / 100)
+
+    def open_in_mpv(self):
+        current_video = self.media_player.source().toString()
+        if current_video:
+            # Remove the 'file://' prefix if present
+            video_path = current_video.replace('file://', '')
+            try:
+                subprocess.Popen(['mpv', video_path])
+            except FileNotFoundError:
+                print("Error: MPV player not found. Make sure it's installed and in your system PATH.")
+        else:
+            print("No video is currently selected.")
+
+    def closeEvent(self, event):
+        self.download_thread.stop()
+        self.download_thread.wait(1000)
+        self.download_thread.terminate()
+        self.cam.logout()
+        super().closeEvent(event)
+
+def read_config(props_path: str) -> dict:
+    """Reads in a properties file into variables.
+
+    NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+    # secrets.cfg
+        [camera]
+        ip={ip_address}
+        username={username}
+        password={password}
+    """
+    config = RawConfigParser()
+    assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+    config.read(props_path)
+    return config
+
+
+def signal_handler(sig, frame):
+    print("Exiting the application...")
+    sys.exit(0)
+    cam.logout()
+    QApplication.quit()
+
+
+
+if __name__ == '__main__':
+    signal.signal(signal.SIGINT, signal_handler)
+    
+    parser = argparse.ArgumentParser(description="Reolink Video Review GUI")
+    parser.add_argument('--main', action='https://github.com/store_true', help="Search for main channel instead of sub channel")
+    parser.add_argument('files', nargs='*', help="Optional video file names to process")
+    args = parser.parse_args()
+
+    config = read_config('camera.cfg')
+
+    ip = config.get('camera', 'ip')
+    un = config.get('camera', 'username')
+    pw = config.get('camera', 'password')
+    video_storage_dir = config.get('camera', 'video_storage_dir')
+
+# Connect to camera
+    cam = Camera(ip, un, pw, https=True)
+
+    start = dt.combine(dt.now(), dt.min.time())
+    end = dt.now()
+
+    streamtype = 'sub' if not args.main else 'main'
+    processed_motions = cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0)
+    processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1)
+
+    start = dt.now() - timedelta(days=1)
+    end = dt.combine(start, dt.max.time())
+    processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0)
+    processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1)
+
+
+    if len(processed_motions) == 0:
+        print("Camera did not return any video?!")
+
+    video_files = []
+    for i, motion in enumerate(processed_motions):
+        fname = motion['filename']
+        print("Processing %s" % (fname))
+        video_files.append(fname)
+
+    video_files.extend([os.path.basename(file) for file in args.files])
+    app = QApplication(sys.argv)
+    player = VideoPlayer(video_files)
+    player.resize(2400, 1000)
+    player.show()
+    sys.exit(app.exec())
diff --git a/make-and-publish-package.sh b/make-and-publish-package.sh
new file mode 100755
index 0000000..bf414e6
--- /dev/null
+++ b/make-and-publish-package.sh
@@ -0,0 +1,3 @@
+rm -rf dist
+python setup.py sdist bdist_wheel
+twine upload dist/*
\ No newline at end of file
diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py
new file mode 100644
index 0000000..ce35f76
--- /dev/null
+++ b/reolinkapi/__init__.py
@@ -0,0 +1,4 @@
+from reolinkapi.handlers.api_handler import APIHandler
+from .camera import Camera
+
+__version__ = "0.4.1"
diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py
new file mode 100644
index 0000000..7371b20
--- /dev/null
+++ b/reolinkapi/camera.py
@@ -0,0 +1,43 @@
+from reolinkapi.handlers.api_handler import APIHandler
+
+
+class Camera(APIHandler):
+
+    def __init__(self, ip: str,
+                 username: str = "admin",
+                 password: str = "",
+                 https: bool = False,
+                 defer_login: bool = False,
+                 profile: str = "main",
+                 **kwargs):
+        """
+        Initialise the Camera object by passing the ip address.
+        The default details {"username":"admin", "password":""} will be used if nothing passed
+        For deferring the login to the camera, just pass defer_login = True.
+        For connecting to the camera behind a proxy pass a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
+        :param ip:
+        :param username:
+        :param password:
+        :param https: connect to the camera over https
+        :param defer_login: defer the login process
+        :param proxy: Add a proxy dict for requests to consume.
+        eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
+        More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
+        """
+        if profile not in ["main", "sub"]:
+            raise Exception("Profile argument must be either \"main\" or \"sub\"")
+
+        # For when you need to connect to a camera behind a proxy, pass
+        # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
+        APIHandler.__init__(self, ip, username, password, https=https, **kwargs)
+
+        # Normal call without proxy:
+        # APIHandler.__init__(self, ip, username, password)
+
+        self.ip = ip
+        self.username = username
+        self.password = password
+        self.profile = profile
+
+        if not defer_login:
+            super().login()
diff --git a/reolinkapi/handlers/__init__.py b/reolinkapi/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py
new file mode 100644
index 0000000..2ef7464
--- /dev/null
+++ b/reolinkapi/handlers/api_handler.py
@@ -0,0 +1,144 @@
+import requests
+from typing import Dict, List, Optional, Union
+from reolinkapi.mixins.alarm import AlarmAPIMixin
+from reolinkapi.mixins.device import DeviceAPIMixin
+from reolinkapi.mixins.display import DisplayAPIMixin
+from reolinkapi.mixins.download import DownloadAPIMixin
+from reolinkapi.mixins.image import ImageAPIMixin
+from reolinkapi.mixins.motion import MotionAPIMixin
+from reolinkapi.mixins.network import NetworkAPIMixin
+from reolinkapi.mixins.ptz import PtzAPIMixin
+from reolinkapi.mixins.record import RecordAPIMixin
+from reolinkapi.handlers.rest_handler import Request
+from reolinkapi.mixins.stream import StreamAPIMixin
+from reolinkapi.mixins.system import SystemAPIMixin
+from reolinkapi.mixins.user import UserAPIMixin
+from reolinkapi.mixins.zoom import ZoomAPIMixin
+from reolinkapi.mixins.nvrdownload import NvrDownloadAPIMixin
+
+
+class APIHandler(AlarmAPIMixin,
+                 DeviceAPIMixin,
+                 DisplayAPIMixin,
+                 DownloadAPIMixin,
+                 ImageAPIMixin,
+                 MotionAPIMixin,
+                 NetworkAPIMixin,
+                 PtzAPIMixin,
+                 RecordAPIMixin,
+                 SystemAPIMixin,
+                 UserAPIMixin,
+                 ZoomAPIMixin,
+                 StreamAPIMixin,
+                 NvrDownloadAPIMixin):
+    """
+    The APIHandler class is the backend part of the API, the actual API calls
+    are implemented in Mixins.
+    This handles communication directly with the camera.
+    Current camera's tested: RLC-411WS
+
+    All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/
+    """
+
+    def __init__(self, ip: str, username: str, password: str, https: bool = False, **kwargs):
+        """
+        Initialise the Camera API Handler (maps api calls into python)
+        :param ip:
+        :param username:
+        :param password:
+        :param https: connect over https
+        :param proxy: Add a proxy dict for requests to consume.
+        eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
+        More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
+        """
+        scheme = 'https' if https else 'http'
+        self.url = f"{scheme}://{ip}/cgi-bin/api.cgi"
+        self.ip = ip
+        self.token = None
+        self.username = username
+        self.password = password
+        Request.proxies = kwargs.get("proxy")  # Defaults to None if key isn't found
+        Request.session = kwargs.get("session")  # Defaults to None if key isn't found
+
+    def login(self) -> bool:
+        """
+        Get login token
+        Must be called first, before any other operation can be performed
+        :return: bool
+        """
+        try:
+            body = [{"cmd": "Login", "action": 0,
+                     "param": {"User": {"userName": self.username, "password": self.password}}}]
+            param = {"cmd": "Login", "token": "null"}
+            response = Request.post(self.url, data=body, params=param)
+            if response is not None:
+                data = response.json()[0]
+                code = data["code"]
+                if int(code) == 0:
+                    self.token = data["value"]["Token"]["name"]
+                    print("Login success")
+                    return True
+                print(self.token)
+                return False
+            else:
+                # TODO: Verify this change w/ owner. Delete old code if acceptable.
+                #  A this point, response is NoneType. There won't be a status code property.
+                # print("Failed to login\nStatus Code:", response.status_code)
+                print("Failed to login\nResponse was null.")
+                return False
+        except Exception as e:
+            print("Error Login\n", e)
+            raise
+
+    def logout(self) -> bool:
+        """
+        Logout of the camera
+        :return: bool
+        """
+        try:
+            data = [{"cmd": "Logout", "action": 0}]
+            self._execute_command('Logout', data)
+            # print(ret)
+            return True
+        except Exception as e:
+            print("Error Logout\n", e)
+            return False
+
+    def _execute_command(self, command: str, data: List[Dict], multi: bool = False) -> \
+            Optional[Union[Dict, bool]]:
+        """
+        Send a POST request to the IP camera with given data.
+        :param command: name of the command to send
+        :param data: object to send to the camera (send as json)
+        :param multi: whether the given command name should be added to the
+        url parameters of the request. Defaults to False. (Some multi-step
+        commands seem to not have a single command name)
+        :return: response JSON as python object
+        """
+        params = {"token": self.token, 'cmd': command}
+        if multi:
+            del params['cmd']
+        try:
+            if self.token is None:
+                raise ValueError("Login first")
+            if command == 'Download' or command == 'Playback':
+                # Special handling for downloading an mp4
+                # Pop the filepath from data
+                tgt_filepath = data[0].pop('filepath')
+                # Apply the data to the params
+                params.update(data[0])
+                with requests.get(self.url, params=params, stream=True, verify=False, timeout=(1, None), proxies=Request.proxies) as req:
+                    if req.status_code == 200:
+                        with open(tgt_filepath, 'wb') as f:
+                            f.write(req.content)
+                        return True
+                    else:
+                        print(f'Error received: {req.status_code}')
+                        return False
+
+            else:
+                response = Request.post(self.url, data=data, params=params)
+                return response.json()
+        except Exception as e:
+            print(f"Command {command} failed: {e}")
+            raise
diff --git a/reolinkapi/handlers/rest_handler.py b/reolinkapi/handlers/rest_handler.py
new file mode 100644
index 0000000..a9512f4
--- /dev/null
+++ b/reolinkapi/handlers/rest_handler.py
@@ -0,0 +1,52 @@
+import requests
+from typing import List, Dict, Union, Optional
+
+
+class Request:
+    proxies = None
+    session = None
+
+    @staticmethod
+    def __getSession():
+        reqHandler = requests
+        if Request.session is not None:
+            reqHandler = Request.session
+        return reqHandler
+
+    @staticmethod
+    def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \
+            Optional[requests.Response]:
+        """
+        Post request
+        :param params:
+        :param url:
+        :param data:
+        :return:
+        """
+        try:
+            headers = {'content-type': 'application/json'}
+            r = Request.__getSession().post(url, verify=False, params=params, json=data, headers=headers,
+                              proxies=Request.proxies)
+            if r.status_code == 200:
+                return r
+            else:
+                raise ValueError(f"Http Request had non-200 Status: {r.status_code}", r.status_code)
+        except Exception as e:
+            print("Post Error\n", e)
+            raise
+
+    @staticmethod
+    def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]:
+        """
+        Get request
+        :param url:
+        :param params:
+        :param timeout:
+        :return:
+        """
+        try:
+            data = Request.__getSession().get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies)
+            return data
+        except Exception as e:
+            print("Get Error\n", e)
+            raise
diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reolinkapi/mixins/alarm.py b/reolinkapi/mixins/alarm.py
new file mode 100644
index 0000000..53bc6ee
--- /dev/null
+++ b/reolinkapi/mixins/alarm.py
@@ -0,0 +1,14 @@
+from typing import Dict
+
+
+class AlarmAPIMixin:
+    """API calls for getting device alarm information."""
+
+    def get_alarm_motion(self) -> Dict:
+        """
+        Gets the device alarm motion
+        See examples/response/GetAlarmMotion.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetAlarm", "action": 1, "param": {"Alarm": {"channel": 0, "type": "md"}}}]
+        return self._execute_command('GetAlarm', body)
diff --git a/reolinkapi/mixins/device.py b/reolinkapi/mixins/device.py
new file mode 100644
index 0000000..21b7961
--- /dev/null
+++ b/reolinkapi/mixins/device.py
@@ -0,0 +1,49 @@
+from typing import List, Dict
+
+
+class DeviceAPIMixin:
+    """API calls for getting device information."""
+    DEFAULT_HDD_ID = [0]
+
+    def set_device_name(self, name: str) -> bool:
+        """
+        Set the device name of the camera.
+        :param name: The new name for the device
+        :return: bool indicating success
+        """
+        body = [{"cmd": "SetDevName", "action": 0, "param": {"DevName": {"name": name}}}]
+        self._execute_command('SetDevName', body)
+        print(f"Successfully set device name to: {name}")
+        return True
+
+    def get_device_name(self) -> Dict:
+        """
+        Get the device name of the camera.
+        :return: Dict containing the device name
+        """
+        body = [{"cmd": "GetDevName", "action": 0, "param": {}}]
+        return self._execute_command('GetDevName', body)
+    
+    def get_hdd_info(self) -> Dict:
+        """
+        Gets all HDD and SD card information from Camera
+        See examples/response/GetHddInfo.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}]
+        return self._execute_command('GetHddInfo', body)
+
+    def format_hdd(self, hdd_id: List[float] = None) -> bool:
+        """
+        Format specified HDD/SD cards with their id's
+        :param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card)
+        :return: bool
+        """
+        if hdd_id is None:
+            hdd_id = self.DEFAULT_HDD_ID
+        body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}]
+        r_data = self._execute_command('Format', body)[0]
+        if r_data["value"]["rspCode"] == 200:
+            return True
+        print("Could not format HDD/SD. Camera responded with:", r_data["value"])
+        return False
diff --git a/reolinkapi/mixins/display.py b/reolinkapi/mixins/display.py
new file mode 100644
index 0000000..c44c04b
--- /dev/null
+++ b/reolinkapi/mixins/display.py
@@ -0,0 +1,57 @@
+from typing import Dict
+
+
+class DisplayAPIMixin:
+    """API calls related to the current image (osd, on screen display)."""
+
+    def get_osd(self) -> Dict:
+        """
+        Get OSD information.
+        See examples/response/GetOsd.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}]
+        return self._execute_command('GetOsd', body)
+
+    def get_mask(self) -> Dict:
+        """
+        Get the camera mask information.
+        See examples/response/GetMask.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}]
+        return self._execute_command('GetMask', body)
+
+    def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: bool = 0,
+                osd_channel_name: str = "", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0,
+                osd_time_pos: str = "Lower Right", osd_watermark_enabled: bool = 0) -> bool:
+        """
+        Set OSD
+        :param bg_color: bool
+        :param channel: int channel id
+        :param osd_channel_enabled: bool
+        :param osd_channel_name: string channel name
+        :param osd_channel_pos: string channel position
+            ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
+        :param osd_time_enabled: bool
+        :param osd_time_pos: string time position
+            ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
+        :return: whether the action was successful
+        """
+        body = [{"cmd": "SetOsd", "action": 1,
+                 "param": {
+                    "Osd": {
+                        "bgcolor": bg_color,
+                        "channel": channel,
+                        "osdChannel": {
+                            "enable": osd_channel_enabled, "name": osd_channel_name,
+                            "pos": osd_channel_pos
+                        },
+                        "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos},
+                        "watermark": osd_watermark_enabled,
+                    }}}]
+        r_data = self._execute_command('SetOsd', body)[0]
+        if 'value' in r_data and r_data["value"]["rspCode"] == 200:
+            return True
+        print("Could not set OSD. Camera responded with status:", r_data["error"])
+        return False
diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py
new file mode 100644
index 0000000..3580930
--- /dev/null
+++ b/reolinkapi/mixins/download.py
@@ -0,0 +1,21 @@
+class DownloadAPIMixin:
+    """API calls for downloading video files."""
+    def get_file(self, filename: str, output_path: str, method = 'Playback') -> bool:
+        """
+        Download the selected video file
+        On at least Trackmix Wifi, it was observed that the Playback method
+        yields much improved download speeds over the Download method, for
+        unknown reasons.
+        :return: response json
+        """
+        body = [
+            {
+                "cmd": method,
+                "source": filename,
+                "output": filename,
+                "filepath": output_path
+            }
+        ]
+        resp = self._execute_command(method, body)
+
+        return resp
diff --git a/reolinkapi/mixins/image.py b/reolinkapi/mixins/image.py
new file mode 100644
index 0000000..8e30568
--- /dev/null
+++ b/reolinkapi/mixins/image.py
@@ -0,0 +1,103 @@
+from typing import Dict
+
+
+class ImageAPIMixin:
+    """API calls for image settings."""
+
+    def set_adv_image_settings(self,
+                               anti_flicker: str = 'Outdoor',
+                               exposure: str = 'Auto',
+                               gain_min: float = 1,
+                               gain_max: float = 62,
+                               shutter_min: float = 1,
+                               shutter_max: float = 125,
+                               blue_gain: float = 128,
+                               red_gain: float = 128,
+                               white_balance: str = 'Auto',
+                               day_night: str = 'Auto',
+                               back_light: str = 'DynamicRangeControl',
+                               blc: float = 128,
+                               drc: float = 128,
+                               rotation: float = 0,
+                               mirroring: float = 0,
+                               nr3d: float = 1) -> Dict:
+        """
+        Sets the advanced camera settings.
+
+        :param anti_flicker: string
+        :param exposure: string
+        :param gain_min: int
+        :param gain_max: string
+        :param shutter_min: int
+        :param shutter_max: int
+        :param blue_gain: int
+        :param red_gain: int
+        :param white_balance: string
+        :param day_night: string
+        :param back_light: string
+        :param blc: int
+        :param drc: int
+        :param rotation: int
+        :param mirroring: int
+        :param nr3d: int
+        :return: response
+        """
+        body = [{
+            "cmd": "SetIsp",
+            "action": 0,
+            "param": {
+                "Isp": {
+                    "channel": 0,
+                    "antiFlicker": anti_flicker,
+                    "exposure": exposure,
+                    "gain": {"min": gain_min, "max": gain_max},
+                    "shutter": {"min": shutter_min, "max": shutter_max},
+                    "blueGain": blue_gain,
+                    "redGain": red_gain,
+                    "whiteBalance": white_balance,
+                    "dayNight": day_night,
+                    "backLight": back_light,
+                    "blc": blc,
+                    "drc": drc,
+                    "rotation": rotation,
+                    "mirroring": mirroring,
+                    "nr3d": nr3d
+                }
+            }
+        }]
+        return self._execute_command('SetIsp', body)
+
+    def set_image_settings(self,
+                           brightness: float = 128,
+                           contrast: float = 62,
+                           hue: float = 1,
+                           saturation: float = 125,
+                           sharpness: float = 128) -> Dict:
+        """
+        Sets the camera image settings.
+
+        :param brightness: int
+        :param contrast: string
+        :param hue: int
+        :param saturation: int
+        :param sharpness: int
+        :return: response
+        """
+        body = [
+            {
+                "cmd": "SetImage",
+                "action": 0,
+                "param": {
+                    "Image": {
+                        "bright": brightness,
+                        "channel": 0,
+                        "contrast": contrast,
+                        "hue": hue,
+                        "saturation": saturation,
+                        "sharpen": sharpness
+                    }
+                }
+            }
+        ]
+
+        return self._execute_command('SetImage', body)
diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py
new file mode 100644
index 0000000..6aada63
--- /dev/null
+++ b/reolinkapi/mixins/motion.py
@@ -0,0 +1,85 @@
+from typing import Union, List, Dict
+from datetime import datetime as dt
+
+
+# Type hints for input and output of the motion api response
+RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, float, Dict[str, str]]]]
+PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]]
+
+
+class MotionAPIMixin:
+    """API calls for past motion alerts."""
+    def get_motion_files(self, start: dt, end: dt = dt.now(),
+                         streamtype: str = 'sub', channel = 0) -> PROCESSED_MOTION_LIST_TYPE:
+        """
+        Get the timestamps and filenames of motion detection events for the time range provided.
+
+        Args:
+            start: the starting time range to examine
+            end: the end time of the time range to examine
+            streamtype: 'main' or 'sub' - the stream to examine
+        :return: response json
+        """
+        search_params = {
+            'Search': {
+                'channel': channel,
+                'streamType': streamtype,
+                'onlyStatus': 0,
+                'StartTime': {
+                    'year': start.year,
+                    'mon': start.month,
+                    'day': start.day,
+                    'hour': start.hour,
+                    'min': start.minute,
+                    'sec': start.second
+                },
+                'EndTime': {
+                    'year': end.year,
+                    'mon': end.month,
+                    'day': end.day,
+                    'hour': end.hour,
+                    'min': end.minute,
+                    'sec': end.second
+                }
+            }
+        }
+        body = [{"cmd": "Search", "action": 1, "param": search_params}]
+
+        resp = self._execute_command('Search', body)[0]
+        if 'value' not in resp:
+            return []
+        values = resp['value']
+        if 'SearchResult' not in values:
+            return []
+        result = values['SearchResult']
+        files = result.get('File', [])
+        if len(files) > 0:
+            # Begin processing files
+            processed_files = self._process_motion_files(files)
+            return processed_files
+        return []
+
+    @staticmethod
+    def _process_motion_files(motion_files: RAW_MOTION_LIST_TYPE) -> PROCESSED_MOTION_LIST_TYPE:
+        """Processes raw list of dicts containing motion timestamps
+        and the filename associated with them"""
+        # Process files
+        processed_motions = []
+        replace_fields = {'mon': 'month', 'sec': 'second', 'min': 'minute'}
+        for file in motion_files:
+            time_range = {}
+            for x in ['Start', 'End']:
+                # Get raw dict
+                raw = file[f'{x}Time']
+                # Replace certain keys
+                for k, v in replace_fields.items():
+                    if k in raw.keys():
+                        raw[v] = raw.pop(k)
+                time_range[x.lower()] = dt(**raw)
+            start, end = time_range.values()
+            processed_motions.append({
+                'start': start,
+                'end': end,
+                'filename': file['name']
+            })
+        return processed_motions
diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py
new file mode 100644
index 0000000..ae44ba4
--- /dev/null
+++ b/reolinkapi/mixins/network.py
@@ -0,0 +1,175 @@
+from typing import Dict
+
+
+class NetworkAPIMixin:
+    """API calls for network settings."""
+    def set_network_settings(self, ip: str, gateway: str, mask: str, dns1: str, dns2: str, mac: str,
+                            use_dhcp: bool = True, auto_dns: bool = True) -> Dict:
+        """
+        Set network settings including IP, gateway, subnet mask, DNS, and connection type (DHCP or Static).
+        
+        :param ip: str
+        :param gateway: str
+        :param mask: str
+        :param dns1: str
+        :param dns2: str
+        :param mac: str
+        :param use_dhcp: bool
+        :param auto_dns: bool
+        :return: Dict
+        """
+        body = [{"cmd": "SetLocalLink", "action": 0, "param": {
+            "LocalLink": {
+                "dns": {
+                    "auto": 1 if auto_dns else 0,
+                    "dns1": dns1,
+                    "dns2": dns2
+                },
+                "mac": mac,
+                "static": {
+                    "gateway": gateway,
+                    "ip": ip,
+                    "mask": mask
+                },
+                "type": "DHCP" if use_dhcp else "Static"
+            }
+        }}]
+        
+        return self._execute_command('SetLocalLink', body)
+        print("Successfully Set Network Settings")
+        return True
+
+    def set_net_port(self, http_port: float = 80, https_port: float = 443, media_port: float = 9000,
+                     onvif_port: float = 8000, rtmp_port: float = 1935, rtsp_port: float = 554) -> bool:
+        """
+        Set network ports
+        If nothing is specified, the default values will be used
+        :param rtsp_port: int
+        :param rtmp_port: int
+        :param onvif_port: int
+        :param media_port: int
+        :param https_port: int
+        :type http_port: int
+        :return: bool
+        """
+        body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": {
+            "httpPort": http_port,
+            "httpsPort": https_port,
+            "mediaPort": media_port,
+            "onvifPort": onvif_port,
+            "rtmpPort": rtmp_port,
+            "rtspPort": rtsp_port
+        }}}]
+        self._execute_command('SetNetPort', body, multi=True)
+        print("Successfully Set Network Ports")
+        return True
+
+    def set_wifi(self, ssid: str, password: str) -> Dict:
+        body = [{"cmd": "SetWifi", "action": 0, "param": {
+            "Wifi": {
+                "ssid": ssid,
+                "password": password
+            }}}]
+        return self._execute_command('SetWifi', body)
+
+    def set_ntp(self, enable: bool = True, interval: int = 1440, port: int = 123, server: str = "pool.ntp.org") -> Dict:
+        """
+        Set NTP settings.
+
+        :param enable: bool
+        :param interval: int
+        :param port: int
+        :param server: str
+        :return: Dict
+        """
+        body = [{"cmd": "SetNtp", "action": 0, "param": {
+            "Ntp": {
+                "enable": int(enable),
+                "interval": interval,
+                "port": port,
+                "server": server
+            }}}]
+        response = self._execute_command('SetNtp', body)
+        print("Successfully Set NTP Settings")
+        return response
+
+    def get_net_ports(self) -> Dict:
+        """
+        Get network ports
+        See examples/response/GetNetworkAdvanced.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetNetPort", "action": 1, "param": {}},
+                {"cmd": "GetUpnp", "action": 0, "param": {}},
+                {"cmd": "GetP2p", "action": 0, "param": {}}]
+        return self._execute_command('GetNetPort', body, multi=True)
+
+    def get_wifi(self) -> Dict:
+        body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
+        return self._execute_command('GetWifi', body)
+
+    def scan_wifi(self) -> Dict:
+        body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
+        return self._execute_command('ScanWifi', body)
+
+    def get_network_general(self) -> Dict:
+        """
+        Get the camera information
+        See examples/response/GetNetworkGeneral.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}]
+        return self._execute_command('GetLocalLink', body)
+
+    def get_network_ddns(self) -> Dict:
+        """
+        Get the camera DDNS network information
+        See examples/response/GetNetworkDDNS.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetDdns", "action": 0, "param": {}}]
+        return self._execute_command('GetDdns', body)
+
+    def get_network_ntp(self) -> Dict:
+        """
+        Get the camera NTP network information
+        See examples/response/GetNetworkNTP.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetNtp", "action": 0, "param": {}}]
+        return self._execute_command('GetNtp', body)
+
+    def get_network_email(self) -> Dict:
+        """
+        Get the camera email network information
+        See examples/response/GetNetworkEmail.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetEmail", "action": 0, "param": {}}]
+        return self._execute_command('GetEmail', body)
+
+    def get_network_ftp(self) -> Dict:
+        """
+        Get the camera FTP network information
+        See examples/response/GetNetworkFtp.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetFtp", "action": 0, "param": {}}]
+        return self._execute_command('GetFtp', body)
+
+    def get_network_push(self) -> Dict:
+        """
+        Get the camera push network information
+        See examples/response/GetNetworkPush.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetPush", "action": 0, "param": {}}]
+        return self._execute_command('GetPush', body)
+
+    def get_network_status(self) -> Dict:
+        """
+        Get the camera status network information
+        See examples/response/GetNetworkGeneral.json for example response data.
+        :return: response json
+        """
+        return self.get_network_general()
diff --git a/reolinkapi/mixins/nvrdownload.py b/reolinkapi/mixins/nvrdownload.py
new file mode 100644
index 0000000..c4e3051
--- /dev/null
+++ b/reolinkapi/mixins/nvrdownload.py
@@ -0,0 +1,54 @@
+from datetime import datetime as dt
+
+class NvrDownloadAPIMixin:
+    """API calls for NvrDownload."""
+    def get_playback_files(self, start: dt, end: dt = dt.now(), channel: int = 0,
+                         streamtype: str = 'sub'):
+        """
+        Get the filenames of the videos for the time range provided.
+
+        Args:
+            start: the starting time range to examine
+            end: the end time of the time range to examine
+            channel: which channel to download from
+            streamtype: 'main' or 'sub' - the stream to examine
+        :return: response json
+        """
+        search_params = {
+            'NvrDownload': {
+                'channel': channel,
+                'iLogicChannel': 0,
+                'streamType': streamtype,
+                'StartTime': {
+                    'year': start.year,
+                    'mon': start.month,
+                    'day': start.day,
+                    'hour': start.hour,
+                    'min': start.minute,
+                    'sec': start.second
+                },
+                'EndTime': {
+                    'year': end.year,
+                    'mon': end.month,
+                    'day': end.day,
+                    'hour': end.hour,
+                    'min': end.minute,
+                    'sec': end.second
+                }
+            }
+        }
+        body = [{"cmd": "NvrDownload", "action": 1, "param": search_params}]
+        try:
+            resp = self._execute_command('NvrDownload', body)[0]
+        except Exception as e:
+            print(f"Error: {e}")
+            return []
+        if 'value' not in resp:
+            return []
+        values = resp['value']
+        if 'fileList' not in values:
+            return []
+        files = values['fileList']
+        if len(files) > 0:
+            return [file['fileName'] for file in files]
+        return []
diff --git a/reolinkapi/mixins/ptz.py b/reolinkapi/mixins/ptz.py
new file mode 100644
index 0000000..fd11dfc
--- /dev/null
+++ b/reolinkapi/mixins/ptz.py
@@ -0,0 +1,156 @@
+from typing import Dict
+
+
+class PtzAPIMixin:
+    """
+    API for PTZ functions.
+    """
+    def get_ptz_check_state(self) -> Dict:
+        """
+        Get PTZ Check State Information that indicates whether calibration is required (0) running (1) or done (2)
+        Value is contained in response[0]["value"]["PtzCheckState"].
+        See examples/response/GetPtzCheckState.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetPtzCheckState", "action": 1, "param": { "channel": 0}}]
+        return self._execute_command('GetPtzCheckState', body)
+
+    def get_ptz_presets(self) -> Dict:
+        """
+        Get ptz presets
+        See examples/response/GetPtzPresets.json for example response data.
+        :return: response json
+        """
+
+        body = [{"cmd": "GetPtzPreset", "action": 1, "param": { "channel": 0}}]
+        return self._execute_command('GetPtzPreset', body)
+
+    def perform_calibration(self) -> Dict:
+        """
+        Do the calibration (like app -> ptz -> three dots -> calibration). Moves camera to all end positions.
+        If not calibrated, your viewpoint of presets might drift. So before setting new presets, or moving to preset,
+        check calibration status (get_ptz_check_state -> 2 = calibrated) and perform calibration if not yet calibrated.
+        As of 2024-01-23 (most recent firmware 3.1.0.1711_23010700 for E1 Zoom) does not do this on startup.
+        Method blocks while calibrating.
+        See examples/response/PtzCheck.json for example response data.
+        :return: response json
+        """
+        data = [{"cmd": "PtzCheck", "action": 0, "param": {"channel": 0}}]
+        return self._execute_command('PtzCheck', data)
+
+    def _send_operation(self, operation: str, speed: float, index: float = None) -> Dict:
+        # Refactored to reduce redundancy
+        param = {"channel": 0, "op": operation, "speed": speed}
+        if index is not None:
+            param['id'] = index
+        data = [{"cmd": "PtzCtrl", "action": 0, "param": param}]
+        return self._execute_command('PtzCtrl', data)
+
+    def _send_noparm_operation(self, operation: str) -> Dict:
+        data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation}}]
+        return self._execute_command('PtzCtrl', data)
+
+    def _send_set_preset(self, enable: float, preset: float = 1, name: str = 'pos1') -> Dict:
+        data = [{"cmd": "SetPtzPreset", "action": 0, "param": { "PtzPreset": {
+            "channel": 0, "enable": enable, "id": preset, "name": name}}}]
+        return self._execute_command('PtzCtrl', data)
+
+    def go_to_preset(self, speed: float = 60, index: float = 1) -> Dict:
+        """
+        Move the camera to a preset location
+        :return: response json
+        """
+        return self._send_operation('ToPos', speed=speed, index=index)
+
+    def add_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
+        """
+        Adds the current camera position to the specified preset.
+        :return: response json
+        """
+        return self._send_set_preset(enable=1, preset=preset, name=name)
+
+    def remove_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
+        """
+        Removes the specified preset
+        :return: response json
+        """
+        return self._send_set_preset(enable=0, preset=preset, name=name)
+
+    def move_right(self, speed: float = 25) -> Dict:
+        """
+        Move the camera to the right
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('Right', speed=speed)
+
+    def move_right_up(self, speed: float = 25) -> Dict:
+        """
+        Move the camera to the right and up
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('RightUp', speed=speed)
+
+    def move_right_down(self, speed: float = 25) -> Dict:
+        """
+        Move the camera to the right and down
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('RightDown', speed=speed)
+
+    def move_left(self, speed: float = 25) -> Dict:
+        """
+        Move the camera to the left
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('Left', speed=speed)
+
+    def move_left_up(self, speed: float = 25) -> Dict:
+        """
+        Move the camera to the left and up
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('LeftUp', speed=speed)
+
+    def move_left_down(self, speed: float = 25) -> Dict:
+        """
+        Move the camera to the left and down
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('LeftDown', speed=speed)
+
+    def move_up(self, speed: float = 25) -> Dict:
+        """
+        Move the camera up.
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('Up', speed=speed)
+
+    def move_down(self, speed: float = 25) -> Dict:
+        """
+        Move the camera down.
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('Down', speed=speed)
+
+    def stop_ptz(self) -> Dict:
+        """
+        Stops the cameras current action.
+        :return: response json
+        """
+        return self._send_noparm_operation('Stop')
+
+    def auto_movement(self, speed: float = 25) -> Dict:
+        """
+        Move the camera in a clockwise rotation.
+        The camera moves self.stop_ptz() is called.
+        :return: response json
+        """
+        return self._send_operation('Auto', speed=speed)
diff --git a/reolinkapi/mixins/record.py b/reolinkapi/mixins/record.py
new file mode 100644
index 0000000..375b5ca
--- /dev/null
+++ b/reolinkapi/mixins/record.py
@@ -0,0 +1,72 @@
+from typing import Dict
+
+
+class RecordAPIMixin:
+    """API calls for the recording settings"""
+
+    def get_recording_encoding(self) -> Dict:
+        """
+        Get the current camera encoding settings for "Clear" and "Fluent" profiles.
+        See examples/response/GetEnc.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}]
+        return self._execute_command('GetEnc', body)
+
+    def get_recording_advanced(self) -> Dict:
+        """
+        Get recording advanced setup data
+        See examples/response/GetRec.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}]
+        return self._execute_command('GetRec', body)
+
+    def set_recording_encoding(self,
+                               audio: float = 0,
+                               main_bit_rate: float = 8192,
+                               main_frame_rate: float = 8,
+                               main_profile: str = 'High',
+                               main_size: str = "2560*1440",
+                               sub_bit_rate: float = 160,
+                               sub_frame_rate: float = 7,
+                               sub_profile: str = 'High',
+                               sub_size: str = '640*480') -> Dict:
+        """
+        Sets the current camera encoding settings for "Clear" and "Fluent" profiles.
+        :param audio: int Audio on or off
+        :param main_bit_rate: int Clear Bit Rate
+        :param main_frame_rate: int Clear Frame Rate
+        :param main_profile: string Clear Profile
+        :param main_size: string Clear Size
+        :param sub_bit_rate: int Fluent Bit Rate
+        :param sub_frame_rate: int Fluent Frame Rate
+        :param sub_profile: string Fluent Profile
+        :param sub_size: string Fluent Size
+        :return: response
+        """
+        body = [
+            {
+                "cmd": "SetEnc",
+                "action": 0,
+                "param": {
+                    "Enc": {
+                        "audio": audio,
+                        "channel": 0,
+                        "mainStream": {
+                            "bitRate": main_bit_rate,
+                            "frameRate": main_frame_rate,
+                            "profile": main_profile,
+                            "size": main_size
+                        },
+                        "subStream": {
+                            "bitRate": sub_bit_rate,
+                            "frameRate": sub_frame_rate,
+                            "profile": sub_profile,
+                            "size": sub_size
+                        }
+                    }
+                }
+            }
+        ]
+        return self._execute_command('SetEnc', body)
diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py
new file mode 100644
index 0000000..76dc239
--- /dev/null
+++ b/reolinkapi/mixins/stream.py
@@ -0,0 +1,67 @@
+import string
+from random import choices
+from typing import Any, Optional
+from urllib import parse
+from io import BytesIO
+
+import requests
+
+try:
+    from PIL.Image import Image, open as open_image
+
+    from reolinkapi.utils.rtsp_client import RtspClient
+
+
+    class StreamAPIMixin:
+        """ API calls for opening a video stream or capturing an image from the camera."""
+
+        def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any:
+            """
+            'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player'
+            Blocking function creates a generator and returns the frames as it is spawned
+            :param callback:
+            :param proxies: Default is none, example: {"host": "localhost", "port": 8000}
+            """
+            rtsp_client = RtspClient(
+                ip=self.ip, username=self.username, password=self.password, profile=self.profile, proxies=proxies, callback=callback)
+            return rtsp_client.open_stream()
+
+        def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]:
+            """
+            Gets a "snap" of the current camera video data and returns a Pillow Image or None
+            :param timeout: Request timeout to camera in seconds
+            :param proxies: http/https proxies to pass to the request object.
+            :return: Image or None
+            """
+            data = {
+                'cmd': 'Snap',
+                'channel': 0,
+                'rs': ''.join(choices(string.ascii_uppercase + string.digits, k=10)),
+                'user': self.username,
+                'password': self.password,
+            }
+            parms = parse.urlencode(data, safe="!").encode("utf-8")
+
+            try:
+                response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout)
+                if response.status_code == 200:
+                    return open_image(BytesIO(response.content))
+                print("Could not retrieve data from camera successfully. Status:", response.status_code)
+                return None
+
+            except Exception as e:
+                print("Could not get Image data\n", e)
+                raise
+except ImportError as err:
+    print("ImportError", err)
+
+    class StreamAPIMixin:
+        """ API calls for opening a video stream or capturing an image from the camera."""
+
+        def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any:
+            raise ImportError(f'open_video_stream requires streaming extra dependencies\nFor instance "pip install '
+                              f'reolinkapi[streaming]"')
+
+        def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional['Image']:
+            raise ImportError(
+                f'get_snap requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"')
diff --git a/reolinkapi/mixins/system.py b/reolinkapi/mixins/system.py
new file mode 100644
index 0000000..dcb590a
--- /dev/null
+++ b/reolinkapi/mixins/system.py
@@ -0,0 +1,44 @@
+from typing import Dict
+
+
+class SystemAPIMixin:
+    """API for accessing general system information of the camera."""
+    def get_general_system(self) -> Dict:
+        """:return: response json"""
+        body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}]
+        return self._execute_command('get_general_system', body, multi=True)
+
+    def get_performance(self) -> Dict:
+        """
+        Get a snapshot of the current performance of the camera.
+        See examples/response/GetPerformance.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetPerformance", "action": 0, "param": {}}]
+        return self._execute_command('GetPerformance', body)
+
+    def get_information(self) -> Dict:
+        """
+        Get the camera information
+        See examples/response/GetDevInfo.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}]
+        return self._execute_command('GetDevInfo', body)
+
+    def reboot_camera(self) -> Dict:
+        """
+        Reboots the camera
+        :return: response json
+        """
+        body = [{"cmd": "Reboot", "action": 0, "param": {}}]
+        return self._execute_command('Reboot', body)
+
+    def get_dst(self) -> Dict:
+        """
+        Get the camera DST information
+        See examples/response/GetDSTInfo.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetTime", "action": 0, "param": {}}]
+        return self._execute_command('GetTime', body)
diff --git a/reolinkapi/mixins/user.py b/reolinkapi/mixins/user.py
new file mode 100644
index 0000000..c382c2d
--- /dev/null
+++ b/reolinkapi/mixins/user.py
@@ -0,0 +1,65 @@
+from typing import Dict
+
+
+class UserAPIMixin:
+    """User-related API calls."""
+    def get_online_user(self) -> Dict:
+        """
+        Return a list of current logged-in users in json format
+        See examples/response/GetOnline.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetOnline", "action": 1, "param": {}}]
+        return self._execute_command('GetOnline', body)
+
+    def get_users(self) -> Dict:
+        """
+        Return a list of user accounts from the camera in json format.
+        See examples/response/GetUser.json for example response data.
+        :return: response json
+        """
+        body = [{"cmd": "GetUser", "action": 1, "param": {}}]
+        return self._execute_command('GetUser', body)
+
+    def add_user(self, username: str, password: str, level: str = "guest") -> bool:
+        """
+        Add a new user account to the camera
+        :param username: The user's username
+        :param password: The user's password
+        :param level: The privilege level 'guest' or 'admin'. Default is 'guest'
+        :return: whether the user was added successfully
+        """
+        body = [{"cmd": "AddUser", "action": 0,
+                 "param": {"User": {"userName": username, "password": password, "level": level}}}]
+        r_data = self._execute_command('AddUser', body)[0]
+        if r_data["value"]["rspCode"] == 200:
+            return True
+        print("Could not add user. Camera responded with:", r_data["value"])
+        return False
+
+    def modify_user(self, username: str, password: str) -> bool:
+        """
+        Modify the user's password by specifying their username
+        :param username: The user which would want to be modified
+        :param password: The new password
+        :return: whether the user was modified successfully
+        """
+        body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}]
+        r_data = self._execute_command('ModifyUser', body)[0]
+        if r_data["value"]["rspCode"] == 200:
+            return True
+        print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}")
+        return False
+
+    def delete_user(self, username: str) -> bool:
+        """
+        Delete a user by specifying their username
+        :param username: The user which would want to be deleted
+        :return: whether the user was deleted successfully
+        """
+        body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}]
+        r_data = self._execute_command('DelUser', body)[0]
+        if r_data["value"]["rspCode"] == 200:
+            return True
+        print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}")
+        return False
diff --git a/reolinkapi/mixins/zoom.py b/reolinkapi/mixins/zoom.py
new file mode 100644
index 0000000..74549e0
--- /dev/null
+++ b/reolinkapi/mixins/zoom.py
@@ -0,0 +1,84 @@
+from typing import Dict
+
+
+class ZoomAPIMixin:
+    """
+    API for zooming and changing focus.
+    Note that the API does not allow zooming/focusing by absolute
+    values rather that changing focus/zoom for a given time.
+    """
+    def _start_operation(self, operation: str, speed: float) -> Dict:
+        data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
+        return self._execute_command('PtzCtrl', data)
+
+    def _stop_zooming_or_focusing(self) -> Dict:
+        """This command stops any ongoing zooming or focusing actions."""
+        data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}]
+        return self._execute_command('PtzCtrl', data)
+
+    def get_zoom_focus(self) -> Dict:
+        """This command returns the current zoom and focus values."""
+        data = [{"cmd": "GetZoomFocus", "action": 0, "param": {"channel": 0}}]
+        return self._execute_command('GetZoomFocus', data)
+
+    def start_zoom_pos(self, position: float) -> Dict:
+        """This command sets the zoom position."""
+        data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "ZoomPos", "pos": position}}}]
+        return self._execute_command('StartZoomFocus', data)
+
+    def start_focus_pos(self, position: float) -> Dict:
+        """This command sets the focus position."""
+        data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "FocusPos", "pos": position}}}]
+        return self._execute_command('StartZoomFocus', data)
+
+    def get_auto_focus(self) -> Dict:
+        """This command returns the current auto focus status."""
+        data = [{"cmd": "GetAutoFocus", "action": 0, "param": {"channel": 0}}]
+        return self._execute_command('GetAutoFocus', data)
+    
+    def set_auto_focus(self, disable: bool) -> Dict:
+        """This command sets the auto focus status."""
+        data = [{"cmd": "SetAutoFocus", "action": 0, "param": {"AutoFocus": {"channel": 0, "disable": 1 if disable else 0}}}]
+        return self._execute_command('SetAutoFocus', data)
+
+    def start_zooming_in(self, speed: float = 60) -> Dict:
+        """
+        The camera zooms in until self.stop_zooming() is called.
+        :return: response json
+        """
+        return self._start_operation('ZoomInc', speed=speed)
+
+    def start_zooming_out(self, speed: float = 60) -> Dict:
+        """
+        The camera zooms out until self.stop_zooming() is called.
+        :return: response json
+        """
+        return self._start_operation('ZoomDec', speed=speed)
+
+    def stop_zooming(self) -> Dict:
+        """
+        Stop zooming.
+        :return: response json
+        """
+        return self._stop_zooming_or_focusing()
+
+    def start_focusing_in(self, speed: float = 32) -> Dict:
+        """
+        The camera focuses in until self.stop_focusing() is called.
+        :return: response json
+        """
+        return self._start_operation('FocusInc', speed=speed)
+
+    def start_focusing_out(self, speed: float = 32) -> Dict:
+        """
+        The camera focuses out until self.stop_focusing() is called.
+        :return: response json
+        """
+        return self._start_operation('FocusDec', speed=speed)
+
+    def stop_focusing(self) -> Dict:
+        """
+        Stop focusing.
+        :return: response json
+        """
+        return self._stop_zooming_or_focusing()
diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reolinkapi/utils/rtsp_client.py b/reolinkapi/utils/rtsp_client.py
new file mode 100644
index 0000000..e260a74
--- /dev/null
+++ b/reolinkapi/utils/rtsp_client.py
@@ -0,0 +1,105 @@
+import os
+from threading import ThreadError
+from typing import Any
+import cv2
+from reolinkapi.utils.util import threaded
+
+
+class RtspClient:
+    """
+    This is a wrapper of the OpenCV VideoCapture method
+    Inspiration from:
+        - https://benhowell.github.io/guide/2015/03/09/opencv-and-web-cam-streaming
+        - https://stackoverflow.com/questions/19846332/python-threading-inside-a-class
+        - https://stackoverflow.com/questions/55828451/video-streaming-from-ip-camera-in-python-using-opencv-cv2-videocapture
+    """
+
+    def __init__(self, ip: str, username: str, password: str, port: float = 554, profile: str = "main",
+                 use_udp: bool = True, callback: Any = None, **kwargs):
+        """
+        RTSP client is used to retrieve frames from the camera in a stream
+
+        :param ip: Camera IP
+        :param username: Camera Username
+        :param password: Camera User Password
+        :param port: RTSP port
+        :param profile: "main" or "sub"
+        :param use_upd: True to use UDP, False to use TCP
+        :param proxies: {"host": "localhost", "port": 8000}
+        """
+        self.capture = None
+        self.thread_cancelled = False
+        self.callback = callback
+
+        capture_options = 'rtsp_transport;'
+        self.ip = ip
+        self.username = username
+        self.password = password
+        self.port = port
+        self.proxy = kwargs.get("proxies")
+        self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}'
+        capture_options += 'udp' if use_udp else 'tcp'
+
+        os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options
+
+        # opens the stream capture, but does not retrieve any frames yet.
+        self._open_video_capture()
+
+    def _open_video_capture(self):
+        # To CAP_FFMPEG or not To ?
+        self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG)
+
+    def _stream_blocking(self):
+        while True:
+            try:
+                if self.capture.isOpened():
+                    ret, frame = self.capture.read()
+                    if ret:
+                        yield frame
+                else:
+                    print("stream closed")
+                    self.capture.release()
+                    return
+            except Exception as e:
+                print(e)
+                self.capture.release()
+                return
+
+    @threaded
+    def _stream_non_blocking(self):
+        while not self.thread_cancelled:
+            try:
+                if self.capture.isOpened():
+                    ret, frame = self.capture.read()
+                    if ret:
+                        self.callback(frame)
+                else:
+                    print("stream is closed")
+                    self.stop_stream()
+            except ThreadError as e:
+                print(e)
+                self.stop_stream()
+
+    def stop_stream(self):
+        self.capture.release()
+        self.thread_cancelled = True
+
+    def open_stream(self):
+        """
+        Opens OpenCV Video stream and returns the result according to the OpenCV documentation
+        https://docs.opencv.org/3.4/d8/dfe/classcv_1_1VideoCapture.html#a473055e77dd7faa4d26d686226b292c1
+        """
+
+        # Reset the capture object
+        if self.capture is None or not self.capture.isOpened():
+            self._open_video_capture()
+
+        print("opening stream")
+
+        if self.callback is None:
+            return self._stream_blocking()
+        else:
+            # reset the thread status if the object was not re-created
+            if not self.thread_cancelled:
+                self.thread_cancelled = False
+            return self._stream_non_blocking()
diff --git a/reolinkapi/utils/util.py b/reolinkapi/utils/util.py
new file mode 100644
index 0000000..83cf0ba
--- /dev/null
+++ b/reolinkapi/utils/util.py
@@ -0,0 +1,11 @@
+from threading import Thread
+
+
+def threaded(fn):
+    def wrapper(*args, **kwargs):
+        thread = Thread(target=fn, args=args, kwargs=kwargs)
+        thread.daemon = True
+        thread.start()
+        return thread
+
+    return wrapper
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..945c9b4
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+.
\ No newline at end of file
diff --git a/resthandle.py b/resthandle.py
deleted file mode 100644
index a5433b1..0000000
--- a/resthandle.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import json
-
-import requests
-import socket
-
-import socks
-
-
-class Request:
-    proxies = None
-
-    @staticmethod
-    def post(url: str, data, params=None) -> requests.Response or None:
-        """
-        Post request
-        :param params:
-        :param url:
-        :param data:
-        :return:
-        """
-        try:
-            headers = {'content-type': 'application/json'}
-            r = requests.post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies)
-            # if params is not None:
-            #     r = requests.post(url, params=params, json=data, headers=headers, proxies=proxies)
-            # else:
-            #     r = requests.post(url, json=data)
-            if r.status_code == 200:
-                return r
-            else:
-                raise ValueError("Status: ", r.status_code)
-        except Exception as e:
-            print("Post Error\n", e)
-            raise
-
-    @staticmethod
-    def get(url, params, timeout=1) -> json or None:
-        """
-        Get request
-        :param url:
-        :param params:
-        :param timeout:
-        :return:
-        """
-        try:
-            data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies)
-            
-            return data
-        except Exception as e:
-            print("Get Error\n", e)
-            raise
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..b36aaf8
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,63 @@
+#!/usr/bin/python3
+import os
+import re
+import codecs
+from setuptools import setup, find_packages
+
+
+def read(*parts):
+    with codecs.open(os.path.join(here, *parts), 'r') as fp:
+        return fp.read()
+
+
+def find_version(*file_paths):
+    version_file = read(*file_paths)
+    version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
+    if version_match:
+        return version_match.group(1)
+    raise RuntimeError("Unable to find version string.")
+
+
+# Package meta-data.
+NAME = 'reolinkapi'
+DESCRIPTION = 'Reolink Camera API client written in Python 3'
+URL = 'https://github.com/ReolinkCameraAPI/reolinkapipy'
+AUTHOR_EMAIL = 'alanoterblanche@gmail.com'
+AUTHOR = 'Benehiko'
+LICENSE = 'GPL-3.0'
+INSTALL_REQUIRES = [
+    'setuptools',
+    'PySocks==1.7.1',
+    'PyYaml==6.0.2',
+    'requests>=2.32.3',
+]
+EXTRAS_REQUIRE = {
+    'streaming': [
+        'numpy==2.0.1',
+        'opencv-python==4.10.0.84',
+        'Pillow==10.4.0',
+    ],
+}
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+# read the contents of your README file
+with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
+    long_description = f.read()
+
+
+setup(
+    name=NAME,
+    python_requires='>=3.12.4',
+    version=find_version('reolinkapi', '__init__.py'),
+    description=DESCRIPTION,
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    author=AUTHOR,
+    author_email=AUTHOR_EMAIL,
+    url=URL,
+    license=LICENSE,
+    install_requires=INSTALL_REQUIRES,
+    packages=find_packages(exclude=['examples', 'tests']),
+    extras_require=EXTRAS_REQUIRE
+)
diff --git a/test.py b/test.py
deleted file mode 100644
index 796f5a2..0000000
--- a/test.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from Camera import Camera
-
-c = Camera("192.168.1.112", "admin", "jUa2kUzi")
-# print("Getting information", c.get_information())
-c.open_video_stream()
diff --git a/tests/test_camera.py b/tests/test_camera.py
new file mode 100644
index 0000000..c5cdf98
--- /dev/null
+++ b/tests/test_camera.py
@@ -0,0 +1,46 @@
+import os
+from configparser import RawConfigParser
+import unittest
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+    """Reads in a properties file into variables.
+
+    NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+    # secrets.cfg
+        [camera]
+        ip={ip_address}
+        username={username}
+        password={password}
+    """
+    config = RawConfigParser()
+    assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+    config.read(props_path)
+    return config
+
+
+class TestCamera(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.config = read_config('../secrets.cfg')
+
+    def setUp(self) -> None:
+        self.cam = Camera(self.config.get('camera', 'ip'), self.config.get('camera', 'username'),
+                          self.config.get('camera', 'password'))
+
+    def test_camera(self):
+        """Test that camera connects and gets a token"""
+        self.assertTrue(self.cam.ip == self.config.get('camera', 'ip'))
+        self.assertTrue(self.cam.token != '')
+
+    def test_snapshot(self):
+        img = self.cam.get_snap()
+        # write Pillow Image to file
+        img.save('./tmp/snaps/camera.jpg')
+        self.assertTrue(os.path.exists('./tmp/snaps/camera.jpg'))
+
+
+if __name__ == '__main__':
+    unittest.main()