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