Repository: ReolinkCameraAPI/reolinkapipy (language: python)

Issue:
Python installation results in split, arbitrary libraries
Hi! Thanks for building this library out. I'd love to use it in some upcoming projects. 

I thought I'd point out that the structure of this repo is such that installing as a python library (e.g., `pip install reolink-api`) results in a mixed bag of libraries (api, utils, Camera, etc...) that don't seem to be able to communicate with each other too well. For instance, running the following after a fresh install:
```from Camera import Camera```
results in a `ModuleNotFoundError`, as the `util` file isn't found and thus can't import `from util import threaded`. 

If the structure of the library were to be changed to something like my suggestion below, it would likely function more like a regular python library and thus would avoid the problem of modules not being found:
_(From the project root)_
```
reolinkapi (this is currently the 'api' folder)
    - Camera.py
    - ConfigHandler.py
    - RtspClient.py
    - resthandle.py
    - util.py
    - (everything else that's already in the `api` folder)
```
Also, in `setup.py`, changing the `NAME` variable to `reolink_api` or `reolinkapi` (matching the directory name change above), as having a `-` in a package name in Python can result in unexpected issues when performing a standard import. 

Anyway, just thought I'd bring this up in case your goal was for this project to behave more like a "standard" Python library. I'm certain there are other ways to maintain a repo than what I've suggested, so I accept that I might be wrong in my assumptions about your project. That said, if you'd like some help in moving your project in the direction I've suggested I'd be happy to help out.

Cheers, 


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 368d535..2836d9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+secrets.cfg
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
diff --git a/Camera.py b/Camera.py
deleted file mode 100644
index a60490a..0000000
--- a/Camera.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from api 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, pass
-        # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
-        APIHandler.__init__(self, ip, username, password, 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/README.md b/README.md
index 19332b7..4fcdd37 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
 
 <p align="center">
  <img alt="Reolink Approval" src="https://img.shields.io/badge/reolink-approved-blue?style=flat-square">
- <img alt="GitHub" src="https://img.shields.io/github/license/ReolinkCameraApi/reolink-python-api?style=flat-square">
- <img alt="GitHub tag (latest SemVer)" src="https://img.shields.io/github/v/tag/ReolinkCameraApi/reolink-python-api?style=flat-square">
- <img alt="PyPI" src="https://img.shields.io/pypi/v/reolink-api?style=flat-square">
+ <img alt="GitHub" src="https://img.shields.io/github/license/ReolinkCameraAPI/reolinkapipy?style=flat-square">
+ <img alt="GitHub tag (latest SemVer)" src="https://img.shields.io/github/v/tag/ReolinkCameraAPI/reolinkapipy?style=flat-square">
+ <img alt="PyPI" src="https://img.shields.io/pypi/v/reolinkapi?style=flat-square">
  <img alt="Discord" src="https://img.shields.io/discord/773257004911034389?style=flat-square">
 </p>
 
diff --git a/api/__init__.py b/api/__init__.py
deleted file mode 100644
index 491da40..0000000
--- a/api/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .APIHandler import APIHandler
-
-__version__ = "0.0.5"
-VERSION = __version__
diff --git a/api/recording.py b/api/recording.py
deleted file mode 100644
index 8827249..0000000
--- a/api/recording.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import requests
-import random
-import string
-from urllib import parse
-from io import BytesIO
-from PIL import Image
-from RtspClient import RtspClient
-
-
-class RecordingAPIMixin:
-    """API calls for recording/streaming image or video."""
-
-    def get_recording_encoding(self) -> object:
-        """
-        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) -> object:
-        """
-        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=0,
-                               main_bit_rate=8192,
-                               main_frame_rate=8,
-                               main_profile='High',
-                               main_size="2560*1440",
-                               sub_bit_rate=160,
-                               sub_frame_rate=7,
-                               sub_profile='High',
-                               sub_size='640*480') -> object:
-        """
-        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)
-
-    ###########
-    # RTSP Stream
-    ###########
-    def open_video_stream(self, callback=None, profile: str = "main", proxies=None):
-        """
-        '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 profile: profile is "main" or "sub"
-        :param proxies: Default is none, example: {"host": "localhost", "port": 8000}
-        """
-        rtsp_client = RtspClient(
-            ip=self.ip, username=self.username, password=self.password, proxies=proxies, callback=callback)
-        return rtsp_client.open_stream()
-
-    def get_snap(self, timeout: int = 3, proxies=None) -> 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
-        :param proxies: http/https proxies to pass to the request object.
-        :return: Image or None
-        """
-        data = {}
-        data['cmd'] = 'Snap'
-        data['channel'] = 0
-        data['rs'] = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
-        data['user'] = self.username
-        data['password'] = self.password
-        parms = parse.urlencode(data).encode("utf-8")
-
-        try:
-            response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout)
-            if response.status_code == 200:
-                return Image.open(BytesIO(response.content))
-            print("Could not retrieve data from camera successfully. Status:", response.stats_code)
-            return None
-
-        except Exception as e:
-            print("Could not get Image data\n", e)
-            raise
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
new file mode 100644
index 0000000..0ba744c
--- /dev/null
+++ b/examples/basic_usage.py
@@ -0,0 +1,11 @@
+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()
diff --git a/examples/download_motions.py b/examples/download_motions.py
new file mode 100644
index 0000000..59ec181
--- /dev/null
+++ b/examples/download_motions.py
@@ -0,0 +1,44 @@
+"""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('../secrets.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(hours=1))
+end = dt.now()
+# Collect motion events between these timestamps for substream
+processed_motions = cam.get_motion_files(start=start, end=end, streamtype='sub')
+
+dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads')
+for i, motion in enumerate(processed_motions):
+    fname = motion['filename']
+    # Download the mp4
+    resp = cam.get_file(fname, output_path=os.path.join(dl_dir, f'motion_event_{i}.mp4'))
diff --git a/examples/streaming_video.py b/examples/streaming_video.py
index 90dc2a9..9049ed8 100644
--- a/examples/streaming_video.py
+++ b/examples/streaming_video.py
@@ -1,6 +1,5 @@
 import cv2
-
-from Camera import Camera
+from reolinkapi import Camera
 
 
 def non_blocking():
diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py
new file mode 100644
index 0000000..0a886c6
--- /dev/null
+++ b/reolinkapi/__init__.py
@@ -0,0 +1,4 @@
+from reolinkapi.handlers.api_handler import APIHandler
+from .camera import Camera
+
+__version__ = "0.1.2"
diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py
new file mode 100644
index 0000000..98c208b
--- /dev/null
+++ b/reolinkapi/camera.py
@@ -0,0 +1,38 @@
+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,
+                 **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
+        """
+        # 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
+
+        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/api/APIHandler.py b/reolinkapi/handlers/api_handler.py
similarity index 58%
rename from api/APIHandler.py
rename to reolinkapi/handlers/api_handler.py
index a4a6f07..46d4637 100644
--- a/api/APIHandler.py
+++ b/reolinkapi/handlers/api_handler.py
@@ -1,26 +1,34 @@
-from .recording import RecordingAPIMixin
-from .zoom import ZoomAPIMixin
-from .device import DeviceAPIMixin
-from .display import DisplayAPIMixin
-from .network import NetworkAPIMixin
-from .system import SystemAPIMixin
-from .user import UserAPIMixin
-from .ptz import PtzAPIMixin
-from .alarm import AlarmAPIMixin
-from .image import ImageAPIMixin
-from resthandle import Request
+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
 
 
-class APIHandler(SystemAPIMixin,
-                 NetworkAPIMixin,
-                 UserAPIMixin,
+class APIHandler(AlarmAPIMixin,
                  DeviceAPIMixin,
                  DisplayAPIMixin,
-                 RecordingAPIMixin,
-                 ZoomAPIMixin,
+                 DownloadAPIMixin,
+                 ImageAPIMixin,
+                 MotionAPIMixin,
+                 NetworkAPIMixin,
                  PtzAPIMixin,
-                 AlarmAPIMixin,
-                 ImageAPIMixin):
+                 RecordAPIMixin,
+                 SystemAPIMixin,
+                 UserAPIMixin,
+                 ZoomAPIMixin,
+                 StreamAPIMixin):
     """
     The APIHandler class is the backend part of the API, the actual API calls
     are implemented in Mixins.
@@ -30,12 +38,13 @@ class APIHandler(SystemAPIMixin,
     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):
+    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
@@ -69,7 +78,10 @@ def login(self) -> bool:
                 print(self.token)
                 return False
             else:
-                print("Failed to login\nStatus Code:", response.status_code)
+                # 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)
@@ -89,7 +101,8 @@ def logout(self) -> bool:
             print("Error Logout\n", e)
             return False
 
-    def _execute_command(self, command, data, multi=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
@@ -105,8 +118,24 @@ def _execute_command(self, command, data, multi=False):
         try:
             if self.token is None:
                 raise ValueError("Login first")
-            response = Request.post(self.url, data=data, params=params)
-            return response.json()
+            if command == 'Download':
+                # 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) 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/resthandle.py b/reolinkapi/handlers/rest_handler.py
similarity index 70%
rename from resthandle.py
rename to reolinkapi/handlers/rest_handler.py
index d2f98b7..66f172a 100644
--- a/resthandle.py
+++ b/reolinkapi/handlers/rest_handler.py
@@ -1,13 +1,13 @@
-import json
-
 import requests
+from typing import List, Dict, Union, Optional
 
 
 class Request:
     proxies = None
 
     @staticmethod
-    def post(url: str, data, params=None) -> requests.Response or None:
+    def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \
+            Optional[requests.Response]:
         """
         Post request
         :param params:
@@ -17,11 +17,8 @@ def post(url: str, data, params=None) -> requests.Response or None:
         """
         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)
+            r = requests.post(url, verify=False, params=params, json=data, headers=headers,
+                              proxies=Request.proxies)
             if r.status_code == 200:
                 return r
             else:
@@ -31,7 +28,7 @@ def post(url: str, data, params=None) -> requests.Response or None:
             raise
 
     @staticmethod
-    def get(url, params, timeout=1) -> json or None:
+    def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]:
         """
         Get request
         :param url:
@@ -41,7 +38,6 @@ def get(url, params, timeout=1) -> json or None:
         """
         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)
diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/alarm.py b/reolinkapi/mixins/alarm.py
similarity index 85%
rename from api/alarm.py
rename to reolinkapi/mixins/alarm.py
index 2f48efb..53bc6ee 100644
--- a/api/alarm.py
+++ b/reolinkapi/mixins/alarm.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
 class AlarmAPIMixin:
     """API calls for getting device alarm information."""
 
-    def get_alarm_motion(self) -> object:
+    def get_alarm_motion(self) -> Dict:
         """
         Gets the device alarm motion
         See examples/response/GetAlarmMotion.json for example response data.
diff --git a/api/device.py b/reolinkapi/mixins/device.py
similarity index 80%
rename from api/device.py
rename to reolinkapi/mixins/device.py
index deee890..684be45 100644
--- a/api/device.py
+++ b/reolinkapi/mixins/device.py
@@ -1,6 +1,11 @@
+from typing import List, Dict
+
+
 class DeviceAPIMixin:
     """API calls for getting device information."""
-    def get_hdd_info(self) -> object:
+    DEFAULT_HDD_ID = [0]
+
+    def get_hdd_info(self) -> Dict:
         """
         Gets all HDD and SD card information from Camera
         See examples/response/GetHddInfo.json for example response data.
@@ -9,12 +14,14 @@ def get_hdd_info(self) -> object:
         body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}]
         return self._execute_command('GetHddInfo', body)
 
-    def format_hdd(self, hdd_id: [int] = [0]) -> bool:
+    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:
diff --git a/api/display.py b/reolinkapi/mixins/display.py
similarity index 53%
rename from api/display.py
rename to reolinkapi/mixins/display.py
index bf2b4ae..5c4c48c 100644
--- a/api/display.py
+++ b/reolinkapi/mixins/display.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
 class DisplayAPIMixin:
     """API calls related to the current image (osd, on screen display)."""
 
-    def get_osd(self) -> object:
+    def get_osd(self) -> Dict:
         """
         Get OSD information.
         See examples/response/GetOsd.json for example response data.
@@ -10,7 +13,7 @@ def get_osd(self) -> object:
         body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}]
         return self._execute_command('GetOsd', body)
 
-    def get_mask(self) -> object:
+    def get_mask(self) -> Dict:
         """
         Get the camera mask information.
         See examples/response/GetMask.json for example response data.
@@ -19,8 +22,8 @@ def get_mask(self) -> object:
         body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}]
         return self._execute_command('GetMask', body)
 
-    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,
+    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") -> bool:
         """
         Set OSD
@@ -28,18 +31,24 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo
         :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_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"]
+        :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}
-                    }
-        }}]
+        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}
+                    }}}]
         r_data = self._execute_command('SetOsd', body)[0]
         if r_data["value"]["rspCode"] == 200:
             return True
diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py
new file mode 100644
index 0000000..ebd1603
--- /dev/null
+++ b/reolinkapi/mixins/download.py
@@ -0,0 +1,18 @@
+class DownloadAPIMixin:
+    """API calls for downloading video files."""
+    def get_file(self, filename: str, output_path: str) -> bool:
+        """
+        Download the selected video file
+        :return: response json
+        """
+        body = [
+            {
+                "cmd": "Download",
+                "source": filename,
+                "output": filename,
+                "filepath": output_path
+            }
+        ]
+        resp = self._execute_command('Download', body)
+
+        return resp
diff --git a/api/image.py b/reolinkapi/mixins/image.py
similarity index 66%
rename from api/image.py
rename to reolinkapi/mixins/image.py
index 6cdb823..8e30568 100644
--- a/api/image.py
+++ b/reolinkapi/mixins/image.py
@@ -1,24 +1,26 @@
+from typing import Dict
+
 
 class ImageAPIMixin:
     """API calls for image settings."""
 
     def set_adv_image_settings(self,
-                               anti_flicker='Outdoor',
-                               exposure='Auto',
-                               gain_min=1,
-                               gain_max=62,
-                               shutter_min=1,
-                               shutter_max=125,
-                               blue_gain=128,
-                               red_gain=128,
-                               white_balance='Auto',
-                               day_night='Auto',
-                               back_light='DynamicRangeControl',
-                               blc=128,
-                               drc=128,
-                               rotation=0,
-                               mirroring=0,
-                               nr3d=1) -> object:
+                               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.
 
@@ -66,11 +68,11 @@ def set_adv_image_settings(self,
         return self._execute_command('SetIsp', body)
 
     def set_image_settings(self,
-                           brightness=128,
-                           contrast=62,
-                           hue=1,
-                           saturation=125,
-                           sharpness=128) -> object:
+                           brightness: float = 128,
+                           contrast: float = 62,
+                           hue: float = 1,
+                           saturation: float = 125,
+                           sharpness: float = 128) -> Dict:
         """
         Sets the camera image settings.
 
diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py
new file mode 100644
index 0000000..b95746d
--- /dev/null
+++ b/reolinkapi/mixins/motion.py
@@ -0,0 +1,80 @@
+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') -> 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': 0,
+                '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]
+        result = resp['value']['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/api/network.py b/reolinkapi/mixins/network.py
similarity index 84%
rename from api/network.py
rename to reolinkapi/mixins/network.py
index 39af7b8..f4fe4a6 100644
--- a/api/network.py
+++ b/reolinkapi/mixins/network.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
 class NetworkAPIMixin:
     """API calls for network settings."""
-    def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935,
-                     rtsp_port=554) -> bool:
+    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
@@ -25,7 +28,7 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port
         print("Successfully Set Network Ports")
         return True
 
-    def set_wifi(self, ssid, password) -> object:
+    def set_wifi(self, ssid: str, password: str) -> Dict:
         body = [{"cmd": "SetWifi", "action": 0, "param": {
             "Wifi": {
                 "ssid": ssid,
@@ -33,7 +36,7 @@ def set_wifi(self, ssid, password) -> object:
             }}}]
         return self._execute_command('SetWifi', body)
 
-    def get_net_ports(self) -> object:
+    def get_net_ports(self) -> Dict:
         """
         Get network ports
         See examples/response/GetNetworkAdvanced.json for example response data.
@@ -44,15 +47,15 @@ def get_net_ports(self) -> object:
                 {"cmd": "GetP2p", "action": 0, "param": {}}]
         return self._execute_command('GetNetPort', body, multi=True)
 
-    def get_wifi(self):
+    def get_wifi(self) -> Dict:
         body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
         return self._execute_command('GetWifi', body)
 
-    def scan_wifi(self):
+    def scan_wifi(self) -> Dict:
         body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
         return self._execute_command('ScanWifi', body)
 
-    def get_network_general(self) -> object:
+    def get_network_general(self) -> Dict:
         """
         Get the camera information
         See examples/response/GetNetworkGeneral.json for example response data.
@@ -61,7 +64,7 @@ def get_network_general(self) -> object:
         body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}]
         return self._execute_command('GetLocalLink', body)
 
-    def get_network_ddns(self) -> object:
+    def get_network_ddns(self) -> Dict:
         """
         Get the camera DDNS network information
         See examples/response/GetNetworkDDNS.json for example response data.
@@ -70,7 +73,7 @@ def get_network_ddns(self) -> object:
         body = [{"cmd": "GetDdns", "action": 0, "param": {}}]
         return self._execute_command('GetDdns', body)
 
-    def get_network_ntp(self) -> object:
+    def get_network_ntp(self) -> Dict:
         """
         Get the camera NTP network information
         See examples/response/GetNetworkNTP.json for example response data.
@@ -79,7 +82,7 @@ def get_network_ntp(self) -> object:
         body = [{"cmd": "GetNtp", "action": 0, "param": {}}]
         return self._execute_command('GetNtp', body)
 
-    def get_network_email(self) -> object:
+    def get_network_email(self) -> Dict:
         """
         Get the camera email network information
         See examples/response/GetNetworkEmail.json for example response data.
@@ -88,7 +91,7 @@ def get_network_email(self) -> object:
         body = [{"cmd": "GetEmail", "action": 0, "param": {}}]
         return self._execute_command('GetEmail', body)
 
-    def get_network_ftp(self) -> object:
+    def get_network_ftp(self) -> Dict:
         """
         Get the camera FTP network information
         See examples/response/GetNetworkFtp.json for example response data.
@@ -97,7 +100,7 @@ def get_network_ftp(self) -> object:
         body = [{"cmd": "GetFtp", "action": 0, "param": {}}]
         return self._execute_command('GetFtp', body)
 
-    def get_network_push(self) -> object:
+    def get_network_push(self) -> Dict:
         """
         Get the camera push network information
         See examples/response/GetNetworkPush.json for example response data.
@@ -106,7 +109,7 @@ def get_network_push(self) -> object:
         body = [{"cmd": "GetPush", "action": 0, "param": {}}]
         return self._execute_command('GetPush', body)
 
-    def get_network_status(self) -> object:
+    def get_network_status(self) -> Dict:
         """
         Get the camera status network information
         See examples/response/GetNetworkGeneral.json for example response data.
diff --git a/api/ptz.py b/reolinkapi/mixins/ptz.py
similarity index 67%
rename from api/ptz.py
rename to reolinkapi/mixins/ptz.py
index 463624e..80841a0 100644
--- a/api/ptz.py
+++ b/reolinkapi/mixins/ptz.py
@@ -1,46 +1,49 @@
+from typing import Dict
+
+
 class PtzAPIMixin:
     """
     API for PTZ functions.
     """
-    def _send_operation(self, operation, speed, index=None):
-        if index is None:
-            data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
-        else:
-            data = [{"cmd": "PtzCtrl", "action": 0, "param": {
-                "channel": 0, "op": operation, "speed": speed, "id": index}}]
+    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):
+    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, operation, enable, preset=1, name='pos1'):
+    def _send_set_preset(self, enable: float, preset: float = 1, name: str = 'pos1') -> Dict:
         data = [{"cmd": "SetPtzPreset", "action": 0, "param": {
             "channel": 0, "enable": enable, "id": preset, "name": name}}]
         return self._execute_command('PtzCtrl', data)
 
-    def go_to_preset(self, speed=60, index=1):
+    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=1, name='pos1'):
+    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('PtzPreset', enable=1, preset=preset, name=name)
+        return self._send_set_preset(enable=1, preset=preset, name=name)
 
-    def remove_preset(self, preset=1, name='pos1'):
+    def remove_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
         """
         Removes the specified preset
         :return: response json
         """
-        return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name)
+        return self._send_set_preset(enable=0, preset=preset, name=name)
 
-    def move_right(self, speed=25):
+    def move_right(self, speed: float = 25) -> Dict:
         """
         Move the camera to the right
         The camera moves self.stop_ptz() is called.
@@ -48,7 +51,7 @@ def move_right(self, speed=25):
         """
         return self._send_operation('Right', speed=speed)
 
-    def move_right_up(self, speed=25):
+    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.
@@ -56,7 +59,7 @@ def move_right_up(self, speed=25):
         """
         return self._send_operation('RightUp', speed=speed)
 
-    def move_right_down(self, speed=25):
+    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.
@@ -64,7 +67,7 @@ def move_right_down(self, speed=25):
         """
         return self._send_operation('RightDown', speed=speed)
 
-    def move_left(self, speed=25):
+    def move_left(self, speed: float = 25) -> Dict:
         """
         Move the camera to the left
         The camera moves self.stop_ptz() is called.
@@ -72,7 +75,7 @@ def move_left(self, speed=25):
         """
         return self._send_operation('Left', speed=speed)
 
-    def move_left_up(self, speed=25):
+    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.
@@ -80,7 +83,7 @@ def move_left_up(self, speed=25):
         """
         return self._send_operation('LeftUp', speed=speed)
 
-    def move_left_down(self, speed=25):
+    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.
@@ -88,7 +91,7 @@ def move_left_down(self, speed=25):
         """
         return self._send_operation('LeftDown', speed=speed)
 
-    def move_up(self, speed=25):
+    def move_up(self, speed: float = 25) -> Dict:
         """
         Move the camera up.
         The camera moves self.stop_ptz() is called.
@@ -96,7 +99,7 @@ def move_up(self, speed=25):
         """
         return self._send_operation('Up', speed=speed)
 
-    def move_down(self, speed=25):
+    def move_down(self, speed: float = 25) -> Dict:
         """
         Move the camera down.
         The camera moves self.stop_ptz() is called.
@@ -104,14 +107,14 @@ def move_down(self, speed=25):
         """
         return self._send_operation('Down', speed=speed)
 
-    def stop_ptz(self):
+    def stop_ptz(self) -> Dict:
         """
         Stops the cameras current action.
         :return: response json
         """
         return self._send_noparm_operation('Stop')
 
-    def auto_movement(self, speed=25):
+    def auto_movement(self, speed: float = 25) -> Dict:
         """
         Move the camera in a clockwise rotation.
         The camera moves self.stop_ptz() is called.
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..5d6e419
--- /dev/null
+++ b/reolinkapi/mixins/stream.py
@@ -0,0 +1,52 @@
+import string
+from random import random
+from typing import Any, Optional
+from urllib import parse
+from io import BytesIO
+
+import requests
+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, 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(random.choices(string.ascii_uppercase + string.digits, k=10)),
+            'user': self.username,
+            'password': self.password,
+        }
+        parms = parse.urlencode(data).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
diff --git a/api/system.py b/reolinkapi/mixins/system.py
similarity index 83%
rename from api/system.py
rename to reolinkapi/mixins/system.py
index 0eadc6a..dcb590a 100644
--- a/api/system.py
+++ b/reolinkapi/mixins/system.py
@@ -1,11 +1,14 @@
+from typing import Dict
+
+
 class SystemAPIMixin:
     """API for accessing general system information of the camera."""
-    def get_general_system(self) -> object:
+    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) -> object:
+    def get_performance(self) -> Dict:
         """
         Get a snapshot of the current performance of the camera.
         See examples/response/GetPerformance.json for example response data.
@@ -14,7 +17,7 @@ def get_performance(self) -> object:
         body = [{"cmd": "GetPerformance", "action": 0, "param": {}}]
         return self._execute_command('GetPerformance', body)
 
-    def get_information(self) -> object:
+    def get_information(self) -> Dict:
         """
         Get the camera information
         See examples/response/GetDevInfo.json for example response data.
@@ -23,7 +26,7 @@ def get_information(self) -> object:
         body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}]
         return self._execute_command('GetDevInfo', body)
 
-    def reboot_camera(self) -> object:
+    def reboot_camera(self) -> Dict:
         """
         Reboots the camera
         :return: response json
@@ -31,11 +34,11 @@ def reboot_camera(self) -> object:
         body = [{"cmd": "Reboot", "action": 0, "param": {}}]
         return self._execute_command('Reboot', body)
 
-    def get_dst(self) -> object:
+    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)
\ No newline at end of file
+        return self._execute_command('GetTime', body)
diff --git a/api/user.py b/reolinkapi/mixins/user.py
similarity index 89%
rename from api/user.py
rename to reolinkapi/mixins/user.py
index 9d430f6..c382c2d 100644
--- a/api/user.py
+++ b/reolinkapi/mixins/user.py
@@ -1,6 +1,9 @@
+from typing import Dict
+
+
 class UserAPIMixin:
     """User-related API calls."""
-    def get_online_user(self) -> object:
+    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.
@@ -9,7 +12,7 @@ def get_online_user(self) -> object:
         body = [{"cmd": "GetOnline", "action": 1, "param": {}}]
         return self._execute_command('GetOnline', body)
 
-    def get_users(self) -> object:
+    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.
@@ -45,7 +48,7 @@ def modify_user(self, username: str, password: str) -> bool:
         r_data = self._execute_command('ModifyUser', body)[0]
         if r_data["value"]["rspCode"] == 200:
             return True
-        print("Could not modify user:", username, "\nCamera responded with:", r_data["value"])
+        print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}")
         return False
 
     def delete_user(self, username: str) -> bool:
@@ -58,5 +61,5 @@ def delete_user(self, username: str) -> bool:
         r_data = self._execute_command('DelUser', body)[0]
         if r_data["value"]["rspCode"] == 200:
             return True
-        print("Could not delete user:", username, "\nCamera responded with:", r_data["value"])
+        print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}")
         return False
diff --git a/api/zoom.py b/reolinkapi/mixins/zoom.py
similarity index 77%
rename from api/zoom.py
rename to reolinkapi/mixins/zoom.py
index 2bf0021..0f5778d 100644
--- a/api/zoom.py
+++ b/reolinkapi/mixins/zoom.py
@@ -1,54 +1,57 @@
+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, speed):
+    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):
+    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 start_zooming_in(self, speed=60):
+    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=60):
+    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):
+    def stop_zooming(self) -> Dict:
         """
         Stop zooming.
         :return: response json
         """
         return self._stop_zooming_or_focusing()
 
-    def start_focusing_in(self, speed=32):
+    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=32):
+    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):
+    def stop_focusing(self) -> Dict:
         """
         Stop focusing.
         :return: response json
diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/RtspClient.py b/reolinkapi/utils/rtsp_client.py
similarity index 83%
rename from RtspClient.py
rename to reolinkapi/utils/rtsp_client.py
index 6cf37c1..e260a74 100644
--- a/RtspClient.py
+++ b/reolinkapi/utils/rtsp_client.py
@@ -1,20 +1,21 @@
 import os
 from threading import ThreadError
-
+from typing import Any
 import cv2
-
-from util import threaded
+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, username, password, port=554, profile="main", use_udp=True, callback=None, **kwargs):
+    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
 
@@ -36,12 +37,8 @@ def __init__(self, ip, username, password, port=554, profile="main", use_udp=Tru
         self.password = password
         self.port = port
         self.proxy = kwargs.get("proxies")
-        self.url = "rtsp://" + self.username + ":" + self.password + "@" + \
-                   self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile
-        if use_udp:
-            capture_options = capture_options + 'udp'
-        else:
-            capture_options = capture_options + 'tcp'
+        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
 
@@ -91,9 +88,6 @@ 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
-
-        :param callback: The function to callback the cv::mat frame to if required to be non-blocking. If this is left
-            as None, then the function returns a generator which is blocking.
         """
 
         # Reset the capture object
diff --git a/util.py b/reolinkapi/utils/util.py
similarity index 92%
rename from util.py
rename to reolinkapi/utils/util.py
index c824002..83cf0ba 100644
--- a/util.py
+++ b/reolinkapi/utils/util.py
@@ -8,4 +8,4 @@ def wrapper(*args, **kwargs):
         thread.start()
         return thread
 
-    return wrapper
\ No newline at end of file
+    return wrapper
diff --git a/requirements.txt b/requirements.txt
index 30b468d..a8aabcd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,6 @@
-requests
-opencv-python
-numpy
-socks
\ No newline at end of file
+numpy==1.19.4
+opencv-python==4.4.0.46
+Pillow==8.0.1
+PySocks==1.7.1
+PyYaml==5.3.1
+requests>=2.18.4
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 98eba70..3764023 100644
--- a/setup.py
+++ b/setup.py
@@ -1,31 +1,8 @@
 #!/usr/bin/python3
-
 import os
 import re
 import codecs
-from setuptools import setup
-
-# Package meta-data.
-NAME = 'reolink-api'
-DESCRIPTION = 'Reolink Camera API written in Python 3.6'
-URL = 'https://github.com/Benehiko/ReolinkCameraAPI'
-AUTHOR_EMAIL = ''
-AUTHOR = 'Benehiko'
-LICENSE = 'GPL-3.0'
-INSTALL_REQUIRES = [
-    'pillow',
-    'pyyaml',
-    'requests>=2.18.4',
-    'numpy',
-    'opencv-python',
-    'pysocks'
-]
-
-
-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()
+from setuptools import setup, find_packages
 
 
 def read(*parts):
@@ -41,32 +18,40 @@ def find_version(*file_paths):
     raise RuntimeError("Unable to find version string.")
 
 
-setup(name=NAME,
-      python_requires='>=3.6.0',
-      version=find_version('api', '__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,
-      py_modules=[
-          'Camera',
-          'ConfigHandler',
-          'RtspClient',
-          'resthandle',
-          'api.APIHandler',
-          'api.device',
-          'api.display',
-          'api.network',
-          'api.ptz',
-          'api.recording',
-          'api.system',
-          'api.user',
-          'api.zoom',
-          'api.alarm',
-          'api.image'
-      ]
-      )
+# 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 = [
+    'numpy==1.19.4',
+    'opencv-python==4.4.0.46',
+    'Pillow==8.0.1',
+    'PySocks==1.7.1',
+    'PyYaml==5.3.1',
+    'requests>=2.18.4',
+]
+
+
+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.6.0',
+    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'])
+)


Test patch:
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..67851d0
--- /dev/null
+++ b/tests/test_camera.py
@@ -0,0 +1,40 @@
+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 != '')
+
+
+if __name__ == '__main__':
+    unittest.main()