diff --git a/README.md b/README.md
index e07de23..c2417f2 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,44 @@
# pyseneye
-A library for interacting with the Seneye range or aquarium and pond sensors
+
+[![Build Status](https://travis-ci.org/mcclown/pyseneye.svg?branch=master)](https://travis-ci.org/mcclown/pyseneye)
+[![Coverage Status](https://coveralls.io/repos/mcclown/pyseneye/badge.svg?branch=master&service=github)](https://coveralls.io/github/mcclown/pyseneye?branch=master)
+
+
+A module for working with the Seneye range of aquarium and pond sensors. Support is provided for the HID/USB driver for the device although it is intended to add support for their API later.
+
+When using this, readings will not be synced to the Seneye.me cloud service. This module is in no way endorsed by Seneye and you use it at your own risk.
+
+Generated documentation can be found [here](http://pyseneye.readthedocs.io/en/latest/)
+
+Quickstart
+----------
+
+Install pyseneye using `pip`: `$ pip install pyseneye`. Once that is complete you can import the SUDevice class and connect to your device.
+
+```python
+>>> from pyseneye.sud import SUDDevice, Action
+>>> d = SUDevice()
+```
+
+Once the class is initialised you can put the Seneye into interactive mode and then retrieve sensor readings.
+
+```python
+>>> d.action(Action.ENTER_INTERACTIVE_MODE)
+>>> s = d.action(Action.SENSOR_READING)
+>>> s.ph
+8.16
+>>> s.nh3
+0.007
+>>> s.temperature
+25.125
+>>> d.action(Action.LEAVE_INTERACTIVE_MODE)
+>>> d.close()
+```
+
+You need access to the USB device, so these calls may require elevated privileges.
+
+Issues & Questions
+------------------
+
+If you have any issues, or questions, please feel free to contact me, or open an issue on GitHub
+
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..2357805
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,56 @@
+pyseneye
+========
+
+|Build Status| |Coverage Status|
+
+A module for working with the Seneye range of aquarium and pond sensors.
+Support is provided for the HID/USB driver for the device although it is
+intended to add support for their API later.
+
+When using this, readings will not be synced to the Seneye.me cloud
+service. This module is in no way endorsed by Seneye and you use it at
+your own risk.
+
+Generated documentation can be found
+`here `__
+
+Quickstart
+----------
+
+Install pyseneye using ``pip``: ``$ pip install pyseneye``. Once that is
+complete you can import the SUDevice class and connect to your device.
+
+.. code:: python
+
+ >>> from pyseneye.sud import SUDDevice, Action
+ >>> d = SUDevice()
+
+Once the class is initialised you can put the Seneye into interactive
+mode and then retrieve sensor readings.
+
+.. code:: python
+
+ >>> d.action(Action.ENTER_INTERACTIVE_MODE)
+ >>> s = d.action(Action.SENSOR_READING)
+ >>> s.ph
+ 8.16
+ >>> s.nh3
+ 0.007
+ >>> s.temperature
+ 25.125
+ >>> d.action(Action.LEAVE_INTERACTIVE_MODE)
+ >>> d.close()
+
+You need access to the USB device, so these calls may require elevated
+privileges.
+
+Issues & Questions
+------------------
+
+If you have any issues, or questions, please feel free to contact me, or
+open an issue on GitHub
+
+.. |Build Status| image:: https://travis-ci.org/mcclown/pyseneye.svg?branch=master
+ :target: https://travis-ci.org/mcclown/pyseneye
+.. |Coverage Status| image:: https://coveralls.io/repos/mcclown/pyseneye/badge.svg?branch=master&service=github
+ :target: https://coveralls.io/github/mcclown/pyseneye?branch=master
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..298ea9e
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,19 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..28de611
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+#
+# Configuration file for the Sphinx documentation builder.
+#
+# This file does only contain a selection of the most common options. For a
+# full list see the documentation:
+# http://www.sphinx-doc.org/en/master/config
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, '/home/steve/git/pyseneye/pyseneye')
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'pyseneye'
+copyright = '2019, Stephen Mc Gowan'
+author = 'Stephen Mc Gowan'
+
+#version = ''
+# The full version, including alpha/beta/rc tags
+#release = ''
+
+import pkg_resources
+try:
+ release = pkg_resources.get_distribution(project).version
+except pkg_resources.DistributionNotFound:
+ print 'To build the documentation, The distribution information of seneye'
+ print 'Has to be available. Either install the package into your'
+ print 'development environment or run "setup.py develop" to setup the'
+ print 'metadata. A virtualenv is recommended!'
+ sys.exit(1)
+del pkg_resources
+
+version = '.'.join(release.split('.')[:2])
+
+
+# -- General configuration ---------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+ 'sphinx.ext.autodoc',
+ 'sphinx.ext.viewcode',
+ 'sphinx.ext.todo',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = None
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'nature'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# The default sidebars (for documents that don't match any pattern) are
+# defined by theme itself. Builtin themes are using these templates by
+# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
+# 'searchbox.html']``.
+#
+# html_sidebars = {}
+
+
+# -- Options for HTMLHelp output ---------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'pyseneyedoc'
+
+
+# -- Options for LaTeX output ------------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+
+ # Latex figure (float) alignment
+ #
+ # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+# author, documentclass [howto, manual, or own class]).
+latex_documents = [
+ (master_doc, 'pyseneye.tex', 'pyseneye Documentation',
+ 'Author', 'manual'),
+]
+
+
+# -- Options for manual page output ------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ (master_doc, 'pyseneye', 'pyseneye Documentation',
+ [author], 1)
+]
+
+
+# -- Options for Texinfo output ----------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (master_doc, 'pyseneye', 'pyseneye Documentation',
+ author, 'pyseneye', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+
+# -- Options for Epub output -------------------------------------------------
+
+# Bibliographic Dublin Core info.
+epub_title = project
+
+# The unique identifier of the text. This can be a ISBN number
+# or the project homepage.
+#
+# epub_identifier = ''
+
+# A unique identification for the text.
+#
+# epub_uid = ''
+
+# A list of files that should not be packed into the epub file.
+epub_exclude_files = ['search.html']
+
+
+# -- Extension configuration -------------------------------------------------
+
+# -- Options for todo extension ----------------------------------------------
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..8d8e74c
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,21 @@
+.. pyseneye documentation master file, created by
+ sphinx-quickstart on Sat Feb 23 20:48:16 2019.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to pyseneye's documentation!
+====================================
+
+.. toctree::
+ :maxdepth: 4
+ :caption: Contents:
+
+ pyseneye
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..27f573b
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/docs/pyseneye.rst b/docs/pyseneye.rst
new file mode 100644
index 0000000..05384e1
--- /dev/null
+++ b/docs/pyseneye.rst
@@ -0,0 +1,22 @@
+pyseneye package
+================
+
+Submodules
+----------
+
+pyseneye.sud module
+-------------------
+
+.. automodule:: pyseneye.sud
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pyseneye
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/pyseneye/__init__.py b/pyseneye/__init__.py
new file mode 100644
index 0000000..2f1b7ba
--- /dev/null
+++ b/pyseneye/__init__.py
@@ -0,0 +1,18 @@
+#
+# Copyright 2019 Stephen Mc Gowan
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""*pyseneye* is to integrate with the Seneye range sensors."""
+
+_VERSION_ = "0.0.1"
diff --git a/pyseneye/sud.py b/pyseneye/sud.py
new file mode 100644
index 0000000..c2be0c3
--- /dev/null
+++ b/pyseneye/sud.py
@@ -0,0 +1,621 @@
+#
+# Copyright 2019 Stephen Mc Gowan
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""*pyseneye.sud* implements the HID interface for the Seneye USB devices."""
+
+import time
+import struct
+from abc import ABC
+from array import array
+from enum import Enum
+
+import usb.core
+import usb.util
+from usb.core import USBError
+
+# used to identify the undelying USB device
+VENDOR_ID = 9463
+PRODUCT_ID = 8708
+
+
+# Based on the structs from Seneye sample C++ code
+# https://github.com/seneye/SUDDriver/blob/master/Cpp/sud_data.h
+
+# little-endian
+ENDIAN = "<"
+
+# [unused], Kelvin, x, y, Par, Lux, PUR
+B_LIGHTMETER = "11s3i2IB"
+
+# Flags, [unused], PH, NH3, Temp, [unused]
+SUDREADING_VALUES = "2s3Hi13s" + B_LIGHTMETER
+
+# Header, cmd, Timestamp
+SUDREADING = ENDIAN + "2sI" + SUDREADING_VALUES + "c"
+
+# Header, cmd, IsKelvin
+SUDLIGHTMETER = ENDIAN + "2s?" + B_LIGHTMETER + "29s"
+
+# These are not specified as a struct, in the original C++ source
+RESPONSE = ENDIAN + "2s?"
+GENERIC_RESPONSE = RESPONSE + "61s"
+HELLOSUD_RESPONSE = RESPONSE + "BH58s"
+
+
+# Decoding the flags, currently unused
+# [unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3,
+# Error, IsKelvin, [unused], PH, NH3, Temperature, [unused]
+SUDREADING_FLAGS = "u2b1b1b1u2u2u2b1b1u3"
+
+
+# Return values expected for each read type
+LIGHT_SENSOR_SUB_VALUES = ",kelvin,kelvin_x,kelvin_y,par,lux,pur,unused"
+SENSOR_RETURN_VALUES = "validation_bytes,timestamp,flags,unused,ph,nh3," + \
+ "temperature,unused,unused" + LIGHT_SENSOR_SUB_VALUES
+LIGHT_SENSOR_RETURN_VALUES = "validation_bytes,is_kelvin,unused" + \
+ LIGHT_SENSOR_SUB_VALUES
+HELLOSUD_RETURN_VALUES = "validation_bytes,ack,device_type,version,unused"
+GENERIC_RETURN_VALUES = "validation_bytes,ack,unused"
+
+
+class Action(Enum):
+ """Actions that can be passed to SUDevice.action()."""
+
+ SENSOR_READING = 0
+ ENTER_INTERACTIVE_MODE = 1
+ LEAVE_INTERACTIVE_MODE = 2
+ LIGHT_READING = 3
+
+
+class DeviceType(Enum):
+ """Differnent type of sensor devices."""
+
+ HOME = 0
+ POND = 1
+ REEF = 3
+
+
+class BaseResponse(ABC): # pylint:disable=R0903
+ """Abstract class for the SUD responses."""
+
+ def __init__(self, raw_data, read_def):
+ """Initialise response, parse data and populate instant attributes.
+
+ :param raw_data: raw binary data, containing response data
+ :param read_def: the definition of the expected data
+ :type raw_data: array('B', [64])
+ :type read_def: ReadDefinition
+ """
+ parsed_values = struct.unpack(read_def.parse_str, raw_data)
+ length = len(parsed_values)
+ expected_values = read_def.return_values.split(",")
+
+ if length != len(expected_values):
+ raise ValueError("Returned parameter number doesn't match " +
+ "expected return parameter number")
+
+ # Loop through received data and populate specified instance variables
+ for i in range(0, length):
+
+ setattr(self, "_{0}".format(expected_values[i]), parsed_values[i])
+
+ # Change the format of this, because it isn't being parsed correctly.
+ self._validation_bytes = raw_data[0:2]
+
+ @property
+ def validation_bytes(self):
+ """Bytes that are used to validate the message is correct.
+
+ :returns: bytes used for validation
+ :rtype: array('B', [2])
+ """
+ return self._validation_bytes
+
+
+class Response(BaseResponse):
+ """Response object, includes ACK status."""
+
+ def __init__(self, raw_data, read_def):
+ """Initialise response object, including an ACK attribute.
+
+ :param raw_data: raw binary data, containing response data
+ :param read_def: the definition of the expected data
+ :type raw_data: array('B', [64])
+ :type read_def: ReadDefinition
+ """
+ self._ack = False
+
+ super().__init__(raw_data, read_def)
+
+ @property
+ def ack(self):
+ """Acknowledgment result.
+
+ :returns: True was process successfully, False if not
+ :rtype: bool
+ """
+ return self._ack
+
+
+class EnterInteractiveResponse(Response):
+ """Received when entering interactive mode. Contains device metadata."""
+
+ def __init__(self, raw_data, read_def):
+ """Initialise enter interactive mode response.
+
+ Includes device type and version.
+
+ :param raw_data: raw binary data, containing response data
+ :param read_def: the definition of the expected data
+ :type raw_data: array('B', [64])
+ :type read_def: ReadDefinition
+ """
+ self._device_type = None
+ self._version = 0
+
+ super().__init__(raw_data, read_def)
+
+ @property
+ def device_type(self):
+ """Get the device type.
+
+ :returns: the device type
+ :rtype: DeviceType
+ """
+ if self._device_type is None:
+ return None
+
+ return DeviceType(self._device_type)
+
+ @property
+ def version(self):
+ """Firmware version of the device.
+
+ :returns: the version
+ :rtype: str
+ """
+ ver = self._version
+
+ major = int(ver / 10000)
+ minor = int((ver / 100) % 100)
+ rev = ver % 100
+
+ return "{0}.{1}.{2}".format(major, minor, rev)
+
+
+class SensorReadingResponse(BaseResponse):
+ """Response which contains all sensor data."""
+
+ # pylint: disable=too-many-instance-attributes
+ # All attributes are required. I will try to break this up later.
+
+ def __init__(self, raw_data, read_def):
+ """Initialise sensor reading response, also populate sensor data.
+
+ :param raw_data: raw binary data, containing response data
+ :param read_def: the definition of the expected data
+ :type raw_data: array('B', [64])
+ :type read_def: ReadDefinition
+ """
+ self._timestamp = 0
+ self._ph = 0
+ self._nh3 = 0
+ self._temperature = 0
+ self._flags = None
+ self._is_kelvin = False
+ self._kelvin = 0
+ self._kelvin_x = 0
+ self._kelvin_y = 0
+ self._par = 0
+ self._lux = 0
+ self._pur = 0
+
+ super().__init__(raw_data, read_def)
+
+ @property
+ def is_light_reading(self):
+ """Is the sensor reading a light reading.
+
+ :returns: True if a light reading, False if a sensor reading.
+ :rtype: bool
+ """
+ rdef = ACTION_DEFINITIONS[Action.LIGHT_READING].read_definitions[0]
+
+ return self._validation_bytes == rdef.validator
+
+ @property
+ def is_kelvin(self):
+ """Is light reading on kelvin line: https://tinyurl.com/yy2wtaz5.
+
+ :returns: True if on kelvin line, False if not
+ :rtype: bool
+ """
+ if self.is_light_reading:
+ return self._is_kelvin
+
+ # Need to read this from flags, not implemented yet
+ return None
+
+ @property
+ def timestamp(self):
+ """Time the reading was taken at.
+
+ (only available for sensor readings)
+
+ :returns: Unix epoch time
+ :rtype: float
+ """
+ return self._timestamp
+
+ @property
+ def ph(self): # pylint:disable=C0103
+ """PH reading from the device.
+
+ :returns: the PH value
+ :rtype: float
+ """
+ return self._ph/100
+
+ @property
+ def nh3(self):
+ """NH3 reading from the device.
+
+ :returns: the NH3 value
+ :rtype: float
+ """
+ return self._nh3/1000
+
+ @property
+ def temperature(self):
+ """Temperature reading from the device.
+
+ :returns: the temperature
+ :rtype: float
+ """
+ return self._temperature/1000
+
+ @property
+ def flags(self):
+ """Raw flags information. Not usable yet.
+
+ :returns: the raw flags bytes
+ :rtype: array('B', [2])
+ """
+ return self._flags
+
+ @property
+ def kelvin(self):
+ """Kelvin value of the light reading.
+
+ :returns: the kelvin value
+ :rtype: int
+ """
+ return self._kelvin
+
+ @property
+ def kelvin_x(self):
+ """X co-ordinate on the CIE colourspace https://tinyurl.com/yy2wtaz5.
+
+ Limited to colors that are near the kelvin line. Check with is_kelvin.
+
+ :returns: X co-ordinate
+ :rtype: int
+ """
+ return self._kelvin_x
+
+ @property
+ def kelvin_y(self):
+ """Y co-ordinate on the CIE colourspace https://tinyurl.com/yy2wtaz5.
+
+ Limited to colors that are near the kelvin line. Check with is_kelvin.
+
+ :returns: Y co-ordinate
+ :rtype: int
+ """
+ return self._kelvin_y
+
+ @property
+ def par(self):
+ """PAR value for light reading.
+
+ :returns: PAR value
+ :rtype: int
+ """
+ return self._par
+
+ @property
+ def lux(self):
+ """LUX value for light reading.
+
+ :returns: LUX value
+ :rtype: int
+ """
+ return self._lux
+
+ @property
+ def pur(self):
+ """PUR value for light reading.
+
+ :returns: PUR value
+ :rtype: int
+ """
+ return self._pur
+
+
+class ActionDefinition:
+ """Definition for action and expected responses."""
+
+ def __init__(self, cmd_str, rdefs):
+ """Initialise action definition.
+
+ :param cmd_str: the command string, to send to the device.
+ :param rdefs: the definition of the expected responses
+ :type cmd_str: str
+ :type rdefs: ReadDefinition[]
+ """
+ self._cmd_str = cmd_str
+ self._rdefs = rdefs
+
+ @property
+ def cmd_str(self):
+ """Command string to write to the device.
+
+ :returns: command string
+ :rtype: str
+ """
+ return self._cmd_str
+
+ @property
+ def read_definitions(self):
+ """Read definition for expected response.
+
+ :returns: the read definitions
+ :rtype: ReadDefinition[]
+ """
+ return self._rdefs
+
+
+class ReadDefinition:
+ """Definition of expected response, including validation and parsing."""
+
+ def __init__(self, parse_str, validator, return_values, return_type):
+ """Initialise read definition of expected response.
+
+ :param parse_str: format string, with structure of raw response data
+ :param validator: bytes that can be used to validate response
+ :param return_values: the names and order of expected return values
+ (comma separated list)
+ :param return_type: BaseResponse subclass that represents the response
+ :type parse_str: str
+ :type validator: array('B', [2])
+ :type return_values: str
+ :type return_type: BaseResponse subclass
+ """
+ self._validator = validator
+ self._parse_str = parse_str
+ self._return_values = return_values
+ self._return_type = return_type
+
+ @property
+ def validator(self):
+ """Bytes that are used for validation of expected read.
+
+ :returns: validation bytes
+ :rtype: array('B', [2])
+ """
+ return self._validator
+
+ @property
+ def parse_str(self):
+ """Parse string, as struct format string.
+
+ :returns: format string
+ :rtype: str
+ """
+ return self._parse_str
+
+ @property
+ def return_values(self):
+ """Comma separate list of expected return value names.
+
+ :returns: comma separated list
+ :rtype: str
+ """
+ return self._return_values
+
+ @property
+ def return_type(self):
+ """Subclass of BaseResponse, the expected response object.
+
+ :returns: expected response object
+ :rtype: BaseResponse subclass
+ """
+ return self._return_type
+
+
+# Concrete definitions of all actions we can take..
+ACTION_DEFINITIONS = {
+ Action.SENSOR_READING: ActionDefinition("READING", [
+ ReadDefinition(
+ GENERIC_RESPONSE,
+ array('B', [0x88, 0x02]),
+ GENERIC_RETURN_VALUES,
+ Response),
+ ReadDefinition(
+ SUDREADING,
+ array('B', [0x00, 0x01]),
+ SENSOR_RETURN_VALUES,
+ SensorReadingResponse)
+ ]),
+
+ Action.LIGHT_READING: ActionDefinition(None, [
+ ReadDefinition(
+ SUDLIGHTMETER,
+ array('B', [0x00, 0x02]),
+ LIGHT_SENSOR_RETURN_VALUES,
+ SensorReadingResponse)
+ ]),
+
+ Action.ENTER_INTERACTIVE_MODE: ActionDefinition("HELLOSUD", [
+ ReadDefinition(
+ HELLOSUD_RESPONSE,
+ array('B', [0x88, 0x01]),
+ HELLOSUD_RETURN_VALUES,
+ EnterInteractiveResponse)
+ ]),
+
+ Action.LEAVE_INTERACTIVE_MODE: ActionDefinition("BYESUD", [
+ ReadDefinition(
+ GENERIC_RESPONSE,
+ array('B', [0x77, 0x01]), # Differs from documented response
+ GENERIC_RETURN_VALUES,
+ Response)
+ ])
+ }
+
+
+class SUDevice:
+ """Encapsulates a Seneye USB Device and it's capabilities."""
+
+ def __init__(self):
+ """Initialise and open connection to Seneye USB Device.
+
+ Allowing for actions to be processed by the Seneye device.
+
+ .. note:: When finished SUDevice.close() should be called, to
+ free the USB device, otherwise subsequent calls may fail.
+
+ .. note:: Device will need to be in interactive mode, before taking
+ any readings. Send Action.ENTER_INTERACTIVE_MODE to do this.
+ Devices can be left in interactive mode but readings will not be
+ cached to be sent to the Seneye.me cloud service later.
+
+ :raises ValueError: If USB device not found.
+ :raises usb.core.USBError: If permissions or communications error
+ occur while trying to connect to USB device.
+
+ :Example:
+ >>> from pyseneye.sud import SUDevice, Action
+ >>> d.action(Action.ENTER_INTERACTIVE_MODE)
+ >>> s = d.action(Action.SENSOR_READING)
+ >>> s.ph
+ 8.16
+ >>> s.nh3
+ 0.007
+ >>> s.temperature
+ 25.125
+ >>> d.action(Action.LEAVE_INTERACTIVE_MODE)
+ >>> d.close()
+ """
+ dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
+
+ if dev is None:
+ raise ValueError('Device not found')
+
+ if dev.is_kernel_driver_active(0):
+ dev.detach_kernel_driver(0)
+
+ dev.set_configuration()
+ usb.util.claim_interface(dev, 0)
+ cfg = dev.get_active_configuration()
+ intf = cfg[(0, 0)]
+
+ self._instance = dev
+
+ ep_in = usb.util.find_descriptor(
+ intf,
+ custom_match=lambda e:
+ usb.util.endpoint_direction(e.bEndpointAddress) ==
+ usb.util.ENDPOINT_IN)
+
+ assert ep_in is not None
+ self._ep_in = ep_in
+
+ ep_out = usb.util.find_descriptor(
+ intf,
+ custom_match=lambda e:
+ usb.util.endpoint_direction(e.bEndpointAddress) ==
+ usb.util.ENDPOINT_OUT)
+
+ assert ep_out is not None
+ self._ep_out = ep_out
+
+ def _write(self, msg):
+
+ return self._instance.write(self._ep_out, msg)
+
+ def _read(self, packet_size=None):
+
+ if packet_size is None:
+ packet_size = self._ep_in.wMaxPacketSize
+
+ return self._instance.read(self._ep_in, packet_size)
+
+ def action(self, cmd, timeout=10000):
+ """Perform action on device.
+
+ The available actions are specified by the Action Enum. These actions
+ can include a single write to the device and potentially multiple
+ reads.
+
+ :raises usb.core.USBError: If having issues connecting to the USB
+ :raises TimeoutError: If read operation times out
+
+ :param cmd: Action to action
+ :param timeout: timeout in milliseconds
+ :type cmd: Action
+ :type timeout: int
+ """
+ cdef = ACTION_DEFINITIONS[cmd]
+
+ if cdef.cmd_str is not None:
+ self._write(cdef.cmd_str)
+
+ start = time.time()
+
+ # Preserve data and rdef, to generate the return value
+ data = None
+ rdef = None
+
+ for rdef in cdef.read_definitions:
+
+ # Re-set while, if there are multiple read defs
+ data = None
+
+ while not data:
+ try:
+ resp = self._read()
+
+ if resp[0:2] == rdef.validator:
+ data = resp
+
+ except USBError:
+ pass
+
+ if ((time.time() - start) * 1000) > timeout:
+ raise TimeoutError("Operation timed out reading response.")
+
+ return rdef.return_type(data, rdef)
+
+ def close(self):
+ """Close connection to USB device and clean up instance."""
+ # re-attach kernel driver
+ usb.util.release_interface(self._instance, 0)
+ self._instance.attach_kernel_driver(0)
+
+ # clean up
+ usb.util.release_interface(self._instance, 0)
+ usb.util.dispose_resources(self._instance)
+ self._instance.reset()
diff --git a/pyseneye/test/test_integration.py b/pyseneye/test/test_integration.py
new file mode 100644
index 0000000..14a93f5
--- /dev/null
+++ b/pyseneye/test/test_integration.py
@@ -0,0 +1,88 @@
+import py.test
+import time
+from unittest.mock import Mock, patch
+
+from pyseneye.sud import SUDevice, Action, DeviceType, Response, EnterInteractiveResponse, SensorReadingResponse
+
+# Requires a device plugged in, currently.
+
+def init_device():
+
+ time.sleep(5)
+
+ d = SUDevice()
+ r = d.action(Action.ENTER_INTERACTIVE_MODE)
+ return d, r
+
+
+
+def test_SUDevice_enter_interactive_mode():
+
+ d, r = init_device()
+ assert r.__class__ == EnterInteractiveResponse
+ assert r.ack == True
+ assert r.device_type == DeviceType.REEF
+ assert r.version != None
+ assert r.version != ""
+
+ d.close()
+
+
+def test_SUDevice_leave_interactive_mode():
+
+ d, r = init_device()
+ assert r.ack == True
+
+ r = d.action(Action.LEAVE_INTERACTIVE_MODE)
+ assert r.ack == True
+ assert r.__class__ == Response
+
+ d.close()
+
+
+def test_SUDevice_get_light_reading():
+
+ d, r = init_device()
+ assert r.ack == True
+
+ r = d.action(Action.LIGHT_READING)
+ assert r.__class__ == SensorReadingResponse
+ assert r.flags == None
+ assert r.is_light_reading == True
+ assert r.ph == 0.0
+ assert r.nh3 == 0.0
+ assert r.temperature == 0.0
+ assert r.kelvin != None
+ assert r.kelvin_x != None
+ assert r.kelvin_y != None
+ assert r.par != None
+ assert r.lux != None
+ assert r.pur != None
+
+ d.close()
+
+
+def test_SUDevice_get_sensor_reading():
+
+ d, r = init_device()
+ assert r.ack == True
+
+ r = d.action(Action.SENSOR_READING)
+ assert r.__class__ == SensorReadingResponse
+ assert r.flags != None
+ assert r.is_light_reading == False
+ assert r.ph >= 7.0
+ assert r.ph <= 9.0
+ assert r.nh3 >= 0.0
+ assert r.nh3 <= 0.1
+ assert r.temperature >= 20.0
+ assert r.temperature <= 28.0
+ assert r.kelvin != None
+ assert r.kelvin_x != None
+ assert r.kelvin_y != None
+ assert r.par != None
+ assert r.lux != None
+ assert r.pur != None
+
+ d.close()
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..5146ded
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+bitstruct==6.0.0
+pytest==4.3.0
+pyusb==1.0.2
+sphinx==1.8.4
+pytest-cov==2.6.1
+pylint==2.2.2
+git-pylint-commit-hook==2.5.1
+flake8==3.7.6
+twine==1.13.0
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..718ee34
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,80 @@
+from __future__ import print_function
+from setuptools import setup, find_packages, Command
+from setuptools.command.test import test as TestCommand
+import io
+import codecs
+import os
+import sys
+
+import pyseneye
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+def read(*filenames, **kwargs):
+ encoding = kwargs.get('encoding', 'utf-8')
+ sep = kwargs.get('sep', '\n')
+ buf = []
+ for filename in filenames:
+ with io.open(filename, encoding=encoding) as f:
+ buf.append(f.read())
+ return sep.join(buf)
+
+
+# Convert README.md with pandoc
+long_description = read('README.rst')
+
+class PyTest(TestCommand):
+ def finalize_options(self):
+ TestCommand.finalize_options(self)
+ self.test_args = ["-rs", "--cov=pyseneye", "--cov-report=term-missing"]
+ self.test_suite = True
+
+ def run_tests(self):
+ import pytest
+ errcode = pytest.main(self.test_args)
+ sys.exit(errcode)
+
+
+class CleanCommand(Command):
+ """Custom clean command to tidy up the project root."""
+ user_options = []
+ def initialize_options(self):
+ pass
+ def finalize_options(self):
+ pass
+ def run(self):
+ os.system('rm -vrf ./build ./dist ./*.pyc ./*.tgz ./*.egg-info')
+
+
+setup(
+ name='pyseneye',
+ version=pyseneye._VERSION_,
+ url='http://github.com/mcclown/pyseneye/',
+ license='Apache Software License',
+ author='Stephen Mc Gowan',
+ tests_require=['pytest'],
+ install_requires=['pyusb>=1.0.2'],
+ cmdclass={'test': PyTest, 'clean': CleanCommand},
+ author_email='mcclown@gmail.com',
+ description='A module for interacting with the Seneye range or aquarium and pond sensors',
+ long_description=long_description,
+ packages=['pyseneye'],
+ include_package_data=True,
+ platforms='any',
+ classifiers = [
+ 'Programming Language :: Python',
+ 'Development Status :: 3 - Alpha',
+ 'Natural Language :: English',
+ 'Environment :: Web Environment',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: OS Independent',
+ 'Topic :: Software Development :: Libraries :: Python Modules',
+ 'Topic :: Home Automation',
+ 'Topic :: System :: Hardware',
+ ],
+ extras_require={
+ 'testing': ['pytest'],
+ }
+)
+
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..8ab1d76
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,11 @@
+# tox (https://tox.readthedocs.io/) is a tool for running tests
+# in multiple virtualenvs. This configuration file will run the
+# test suite on all supported python versions. To use it, "pip install tox"
+# and then run "tox" from this directory.
+
+[tox]
+envlist = py34, py35
+
+[testenv]
+commands = python setup.py test
+deps = -rrequirements.txt