From 2b631ab1d22f3d545e1f24dd914e7a856c996846 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Thu, 21 Feb 2019 15:23:00 +0000 Subject: [PATCH 01/14] Some initial testing code, which seems to be working to pull back data. Next to get some structure and meaning from the data --- pyseneye/sud.py | 142 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 2 files changed, 145 insertions(+) create mode 100644 pyseneye/sud.py create mode 100644 requirements.txt diff --git a/pyseneye/sud.py b/pyseneye/sud.py new file mode 100644 index 0000000..dea36a6 --- /dev/null +++ b/pyseneye/sud.py @@ -0,0 +1,142 @@ +import usb.core, usb.util +import json, bitstruct, sys, threading + +VENDOR_ID=9463 +PRODUCT_ID=8708 + +#Structs from Seneye sample C++ code +#https://github.com/seneye/SUDDriver/blob/master/Cpp/sud_data.h + +#[unused], Kelvin, x, y, Par, Lux, PUR +B_LIGHTMETER = "u64s32s32s32u32u32u8" + +#[unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, +#Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] +SUDREADING_VALUES = "u2b1b1b1u2u2u2b1b1u3u16u16s32u128" + B_LIGHTMETER + +#Timestamp +SUDREADING = "u32" + SUDREADING_VALUES + +#IsKelvin +SUDLIGHTMETER = "b1" + B_LIGHTMETER + +#This isn't specified as a struct but it is required +#Header, Command, ACK +WRITE_RESPONSE = "u8u8b8" + + +class SUDevice: + + def __init__(self): + + 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 + + + @property + def instance(self): + + return self._instance + + + 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 close(self): + # 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() + + +class SUDMonitor: + + def __init__(self, device): + + self._cmd = None + self._stop = True + + self._device = device + + + def monitor_thread(self): + + while not self._stop: + + try: + if self._cmd: + self._device.write(self._cmd) + self._cmd = None + + print(self._device.read()) + except: + pass + + + def run(self): + + self._stop = False + + t = threading.Thread(target=self.monitor_thread) + t.start() + + while not self._stop: + cmd = input("cmd (r|h|b|q):") + + if cmd == "r": + self._cmd = "READING" + elif cmd == "h": + self._cmd = "HELLOSUD" + elif cmd == "b": + self._cmd = "BYESUD" + elif cmd == "q": + self._stop = True + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a1b2c3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +bitstruct==6.0.0 +pytest==4.3.0 +pyusb==1.0.2 From 97b536c67fdb04701a3aeae5f495dfd0323633fc Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Fri, 22 Feb 2019 09:35:34 +0000 Subject: [PATCH 02/14] Reading sensor data now and parsing it. --- pyseneye/sud.py | 95 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index dea36a6..b052d64 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -1,5 +1,5 @@ import usb.core, usb.util -import json, bitstruct, sys, threading +import json, struct, bitstruct, sys, threading, time VENDOR_ID=9463 PRODUCT_ID=8708 @@ -7,22 +7,28 @@ #Structs from Seneye sample C++ code #https://github.com/seneye/SUDDriver/blob/master/Cpp/sud_data.h +ENDIAN = "<" + #[unused], Kelvin, x, y, Par, Lux, PUR -B_LIGHTMETER = "u64s32s32s32u32u32u8" +B_LIGHTMETER = "8s3i2IBc" -#[unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, -#Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] -SUDREADING_VALUES = "u2b1b1b1u2u2u2b1b1u3u16u16s32u128" + B_LIGHTMETER +#Flags (1&2), [reserved], PH, NH3, T, [reserved] +SUDREADING_VALUES = "4Hi16s" + B_LIGHTMETER #Timestamp -SUDREADING = "u32" + SUDREADING_VALUES +SUDREADING = ENDIAN + "2sI" + SUDREADING_VALUES +#struct.unpack("<2cI4Hi16s8s3i2IBc", s) #IsKelvin -SUDLIGHTMETER = "b1" + B_LIGHTMETER +SUDLIGHTMETER = ENDIAN + "2sI" + B_LIGHTMETER #This isn't specified as a struct but it is required #Header, Command, ACK -WRITE_RESPONSE = "u8u8b8" +WRITE_RESPONSE = "2B?" + +#[unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, +#Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] +SUDREADING_FLAGS = "u2b1b1b1u2u2u2b1b1u3" class SUDevice: @@ -83,8 +89,18 @@ def read(self, packet_size = None): return self.instance.read(self._ep_in, packet_size) + def open(self): + + self.write("HELLOSUD") + r = self.read() + print(r) def close(self): + + self.write("BYESUD") + r = self.read() + print(r) + # re-attach kernel driver usb.util.release_interface(self.instance, 0) self.instance.attach_kernel_driver(0) @@ -92,7 +108,68 @@ def close(self): # clean up usb.util.release_interface(self.instance, 0) usb.util.dispose_resources(self.instance) - self.instance.reset() + self.instance.reset() + + + def get_light_reading(self): + + return self.read() + + + def get_sensor_reading(self, timeout = 10000): + + self.write("READING") + result = None + + start = time.time() + + while not result: + try: + r = self.read() + if r[1] != 2: + result = r + except: + pass + + if ((time.time() - start) * 1000) > timeout: + return None + + return SUDData(r) + + +class SUDData: + + def __init__(self, raw_data): + + cmd, ts, flags, unused, ph, nh3, t, *unused = struct.unpack(SUDREADING, raw_data) + + self._ts = ts + self._ph = ph/100 + self._nh3 = nh3/1000 + self._t = t/1000 + self._flags = flags + + @property + def timestamp(self): + return self._ts + + @property + def ph(self): + return self._ph + + @property + def nh3(self): + return self._nh3 + + @property + def temperature(self): + return self._t + + @property + def flags(self): + return self._flags + + class SUDMonitor: From 237790f34b2f3f2ee88d5d39bf2619c8b8a28a79 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sat, 23 Feb 2019 00:10:36 +1100 Subject: [PATCH 03/14] Added more generic functionality around reading messages off the device --- pyseneye/sud.py | 233 +++++++++++++++++++++++++++++++----------------- 1 file changed, 151 insertions(+), 82 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index b052d64..c41f10e 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -1,6 +1,8 @@ import usb.core, usb.util import json, struct, bitstruct, sys, threading, time +from enum import Enum +#used to identify the undelying USB device VENDOR_ID=9463 PRODUCT_ID=8708 @@ -9,28 +11,120 @@ ENDIAN = "<" -#[unused], Kelvin, x, y, Par, Lux, PUR +#[unused], Kelvin, x, y, Par, Lux, PUR, [unused] B_LIGHTMETER = "8s3i2IBc" -#Flags (1&2), [reserved], PH, NH3, T, [reserved] +#Flags, [unused], PH, NH3, Temp, [unused] SUDREADING_VALUES = "4Hi16s" + B_LIGHTMETER -#Timestamp +#Header, cmd, Timestamp SUDREADING = ENDIAN + "2sI" + SUDREADING_VALUES -#struct.unpack("<2cI4Hi16s8s3i2IBc", s) -#IsKelvin +#Header, cmd, IsKelvin SUDLIGHTMETER = ENDIAN + "2sI" + B_LIGHTMETER -#This isn't specified as a struct but it is required -#Header, Command, ACK -WRITE_RESPONSE = "2B?" +#These aren't specified as a struct but I'm treating them as if they were +#Header, Cmd, ACK +RESPONSE = ENDIAN + "2s?" + +GENERIC_RESPONSE = RESPONSE + "61s" + +HELLOSUD_RESPONSE = RESPONSE + "BH58s" + + +# WIP - Decoding the flags #[unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, #Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] SUDREADING_FLAGS = "u2b1b1b1u2u2u2b1b1u3" + +class SUDData: + + def __init__(self, raw_data): + + cmd, ts, flags, unused, ph, nh3, t, *unused = struct.unpack(SUDREADING, raw_data) + + self._ts = ts + self._ph = ph/100 + self._nh3 = nh3/1000 + self._t = t/1000 + self._flags = flags + + @property + def timestamp(self): + return self._ts + + @property + def ph(self): + return self._ph + + @property + def nh3(self): + return self._nh3 + + @property + def temperature(self): + return self._t + + @property + def flags(self): + return self._flags + + +class CommandDefinition: + + def __init__(self, cmd_str, rdefs): + + self._cmd_str = cmd_str + self._rdefs = rdefs + + @property + def cmd_str(self): + + return self._cmd_str + + @property + def read_definitions(self): + + return self._rdefs + + +class ReadDefinition: + + def __init__(self, parse_str, validator): + + self._validator = validator + self._parse_str = parse_str + + @property + def validator(self): + + return self._validator + + @property + def parse_str(self): + + return self._parse_str + + +class Command(Enum): + + READING = 0 + OPEN = 1 + CLOSE = 2 + LIGHT_READING = 3 + + +COMMAND_DEFINITIONS = { + Command.READING: CommandDefinition("READING", [ReadDefinition(GENERIC_RESPONSE, b'\x88\x02'), ReadDefinition(SUDREADING, b'\x00\x01')]), + Command.OPEN: CommandDefinition("HELLOSUD", [ReadDefinition(HELLOSUD_RESPONSE, b'\x88\x01')]), + Command.CLOSE: CommandDefinition("BYESUD", [ReadDefinition(GENERIC_RESPONSE, b'\x88\x05')]), + Command.LIGHT_READING: CommandDefinition(None, [ReadDefinition(SUDLIGHTMETER, b'\x00\x02')]) + } + + class SUDevice: def __init__(self): @@ -71,105 +165,80 @@ def __init__(self): self._ep_out = ep_out - @property - def instance(self): - - return self._instance + def _write(self, msg): + return self._instance.write(self._ep_out, msg) - def write(self, msg): - return self.instance.write(self._ep_out, msg) - - - def read(self, packet_size = None): + 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) + return self._instance.read(self._ep_in, packet_size) - def open(self): - - self.write("HELLOSUD") - r = self.read() - print(r) def close(self): - self.write("BYESUD") - r = self.read() - print(r) - # re-attach kernel driver - usb.util.release_interface(self.instance, 0) - self.instance.attach_kernel_driver(0) + 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() + usb.util.release_interface(self._instance, 0) + usb.util.dispose_resources(self._instance) + self._instance.reset() - def get_light_reading(self): + def get_data(self, cmd, timeout = 10000): - return self.read() - - - def get_sensor_reading(self, timeout = 10000): - - self.write("READING") - result = None + d = COMMAND_DEFINITIONS[cmd] + + if d.cmd_str is not None: + self._write(d.cmd_str) start = time.time() - while not result: - try: - r = self.read() - if r[1] != 2: - result = r - except: - pass + for rdef in d.read_definitions: - if ((time.time() - start) * 1000) > timeout: - return None - - return SUDData(r) - - -class SUDData: - - def __init__(self, raw_data): - - cmd, ts, flags, unused, ph, nh3, t, *unused = struct.unpack(SUDREADING, raw_data) + if __debug__: + print("validator: {0}".format(rdef.validator)) - self._ts = ts - self._ph = ph/100 - self._nh3 = nh3/1000 - self._t = t/1000 - self._flags = flags - - @property - def timestamp(self): - return self._ts - - @property - def ph(self): - return self._ph + result = None + + while not result: + try: + r = self._read() + + if __debug__: + print("Validation bytes: {0}".format(r[0:2])) + + if r[0:2].tostring() == rdef.validator: + result = r + + if __debug__: + print("Result: {0}".format(r)) + except: + pass + + if ((time.time() - start) * 1000) > timeout: + + if __debug__: + print("Operation timed out") - @property - def nh3(self): - return self._nh3 + return None - @property - def temperature(self): - return self._t + return result - @property - def flags(self): - return self._flags + def get_sensor_reading(self, timeout = 10000): + + r = self.get_data(Command.READING, timeout) + if r is None: + return None + + return SUDData(r) class SUDMonitor: @@ -188,10 +257,10 @@ def monitor_thread(self): try: if self._cmd: - self._device.write(self._cmd) + self._device._write(self._cmd) self._cmd = None - print(self._device.read()) + print(self._device._read()) except: pass From 20deda892eb63db40ac0364925b1a18ca370a679 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sat, 23 Feb 2019 00:58:05 +1100 Subject: [PATCH 04/14] Fixed array comparison and some readability changes --- pyseneye/sud.py | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index c41f10e..d95eb27 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -1,5 +1,6 @@ import usb.core, usb.util import json, struct, bitstruct, sys, threading, time +from array import array from enum import Enum #used to identify the undelying USB device @@ -111,17 +112,39 @@ def parse_str(self): class Command(Enum): - READING = 0 - OPEN = 1 - CLOSE = 2 + SENSOR_READING = 0 + ENTER_INTERACTIVE_MODE = 1 + LEAVE_INTERACTIVE_MODE = 2 LIGHT_READING = 3 COMMAND_DEFINITIONS = { - Command.READING: CommandDefinition("READING", [ReadDefinition(GENERIC_RESPONSE, b'\x88\x02'), ReadDefinition(SUDREADING, b'\x00\x01')]), - Command.OPEN: CommandDefinition("HELLOSUD", [ReadDefinition(HELLOSUD_RESPONSE, b'\x88\x01')]), - Command.CLOSE: CommandDefinition("BYESUD", [ReadDefinition(GENERIC_RESPONSE, b'\x88\x05')]), - Command.LIGHT_READING: CommandDefinition(None, [ReadDefinition(SUDLIGHTMETER, b'\x00\x02')]) + Command.SENSOR_READING: CommandDefinition("READING", [ + ReadDefinition( + GENERIC_RESPONSE, + array('B', [0x88,0x02])), + ReadDefinition( + SUDREADING, + array('B', [0x00, 0x01])) + ]), + + Command.LIGHT_READING: CommandDefinition(None, [ + ReadDefinition( + SUDLIGHTMETER, + array('B', [0x00, 0x02])) + ]), + + Command.ENTER_INTERACTIVE_MODE: CommandDefinition("HELLOSUD", [ + ReadDefinition( + HELLOSUD_RESPONSE, + array('B', [0x88, 0x01])) + ]), + + Command.LEAVE_INTERACTIVE_MODE: CommandDefinition("BYESUD", [ + ReadDefinition( + GENERIC_RESPONSE, + array('B', [0x88, 0x05])) + ]) } @@ -213,7 +236,7 @@ def get_data(self, cmd, timeout = 10000): if __debug__: print("Validation bytes: {0}".format(r[0:2])) - if r[0:2].tostring() == rdef.validator: + if r[0:2] == rdef.validator: result = r if __debug__: @@ -228,12 +251,14 @@ def get_data(self, cmd, timeout = 10000): return None + # TODO Add validation of result and add internal flag for interactive/non-interactive mode + return result def get_sensor_reading(self, timeout = 10000): - r = self.get_data(Command.READING, timeout) + r = self.get_data(Command.SENSOR_READING, timeout) if r is None: return None From 1abb68a18ceacccfc1b35d569afc207d1eadec49 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sat, 23 Feb 2019 16:37:45 +1100 Subject: [PATCH 05/14] Parsing everything correctly apart from light readings now --- pyseneye/sud.py | 147 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 113 insertions(+), 34 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index d95eb27..88a1b6e 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -1,5 +1,6 @@ import usb.core, usb.util import json, struct, bitstruct, sys, threading, time +from abc import ABC, abstractmethod from array import array from enum import Enum @@ -39,35 +40,116 @@ #Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] SUDREADING_FLAGS = "u2b1b1b1u2u2u2b1b1u3" +class Command(Enum): + + SENSOR_READING = 0 + ENTER_INTERACTIVE_MODE = 1 + LEAVE_INTERACTIVE_MODE = 2 + LIGHT_READING = 3 -class SUDData: +class DeviceType(Enum): - def __init__(self, raw_data): + HOME = 0 + POND = 1 + REEF = 3 - cmd, ts, flags, unused, ph, nh3, t, *unused = struct.unpack(SUDREADING, raw_data) - self._ts = ts - self._ph = ph/100 - self._nh3 = nh3/1000 - self._t = t/1000 - self._flags = flags +class BaseResponse(ABC): + + def __init__(self, raw_data, read_def): + + 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 data length doesn't match expected data length") + + for i in range(0, length): + + setattr(self, "_{0}".format(expected_values[i]), parsed_values[i]) + + @property + def validation_bytes(self): + + return self._validation_bytes + + +class InteractiveModeResponse(BaseResponse): + + def __init__(self, raw_data, read_def): + + self._ack = False + + super().__init__(raw_data, read_def) + + + @property + def ack(self): + + return self._ack + + +class EnterInteractiveResponse(InteractiveModeResponse): + + def __init__(self, raw_data, read_def): + self._device_type = None + self._version = 0 + + super().__init__(raw_data, read_def) + + @property + def device_type(self): + + if self._device_type is None: + return None + + return DeviceType(self._device_type) + + @property + def version(self): + + v = self._version + + major = int(v / 10000) + minor = int((v / 100) % 100) + rev = v % 100 + + return "{0}.{1}.{2}".format(major, minor, rev) + + +class SensorReadingResponse(BaseResponse): + + def __init__(self, raw_data, read_def): + + self._timestamp = 0 + self._ph = 0 + self._nh3 = 0 + self._temperature = 0 + self._flags = None + + super().__init__(raw_data, read_def) + + @property + def is_light_reading(self): + return self._validation_bytes == COMMAND_DEFINITIONS[Command.LIGHT_READING].reading_definitions[0].validator @property def timestamp(self): - return self._ts + return self._timestamp @property def ph(self): - return self._ph + return self._ph/100 @property def nh3(self): - return self._nh3 + return self._nh3/1000 @property def temperature(self): - return self._t + return self._temperature/1000 @property def flags(self): @@ -94,10 +176,11 @@ def read_definitions(self): class ReadDefinition: - def __init__(self, parse_str, validator): + def __init__(self, parse_str, validator, return_values): self._validator = validator self._parse_str = parse_str + self._return_values = return_values @property def validator(self): @@ -109,41 +192,46 @@ def parse_str(self): return self._parse_str + @property + def return_values(self): -class Command(Enum): - - SENSOR_READING = 0 - ENTER_INTERACTIVE_MODE = 1 - LEAVE_INTERACTIVE_MODE = 2 - LIGHT_READING = 3 + return self._return_values +SENSOR_RETURN_VALUES = "validation_bytes,timestamp,flags,unused,ph,nh3,temperature,unused,unused,kelvin,kelvin_x,kelvin_y,par,lux,pur,unused" +HELLOSUD_RETURN_VALUES = "validation_bytes,ack,device_type,version,unused" +GENERIC_RETURN_VALUES = "validation_bytes,ack,unused" COMMAND_DEFINITIONS = { Command.SENSOR_READING: CommandDefinition("READING", [ ReadDefinition( GENERIC_RESPONSE, - array('B', [0x88,0x02])), + array('B', [0x88,0x02]), + GENERIC_RETURN_VALUES), ReadDefinition( SUDREADING, - array('B', [0x00, 0x01])) + array('B', [0x00, 0x01]), + SENSOR_RETURN_VALUES) ]), Command.LIGHT_READING: CommandDefinition(None, [ ReadDefinition( SUDLIGHTMETER, - array('B', [0x00, 0x02])) + array('B', [0x00, 0x02]), + SENSOR_RETURN_VALUES) ]), Command.ENTER_INTERACTIVE_MODE: CommandDefinition("HELLOSUD", [ ReadDefinition( HELLOSUD_RESPONSE, - array('B', [0x88, 0x01])) + array('B', [0x88, 0x01]), + HELLOSUD_RETURN_VALUES) ]), Command.LEAVE_INTERACTIVE_MODE: CommandDefinition("BYESUD", [ ReadDefinition( GENERIC_RESPONSE, - array('B', [0x88, 0x05])) + array('B', [0x77, 0x01]), # Differs from documented response + GENERIC_RETURN_VALUES) ]) } @@ -217,6 +305,7 @@ def get_data(self, cmd, timeout = 10000): d = COMMAND_DEFINITIONS[cmd] + # TODO Operation can timeout here, need to add sme error handling if d.cmd_str is not None: self._write(d.cmd_str) @@ -256,16 +345,6 @@ def get_data(self, cmd, timeout = 10000): return result - def get_sensor_reading(self, timeout = 10000): - - r = self.get_data(Command.SENSOR_READING, timeout) - - if r is None: - return None - - return SUDData(r) - - class SUDMonitor: def __init__(self, device): From c7a6c5301dc114ec470de9c2caea6d61b9a17a1a Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sat, 23 Feb 2019 18:46:15 +1100 Subject: [PATCH 06/14] We now parses each command result back to an object, for easy processing. --- pyseneye/sud.py | 207 +++++++++++++++++++++++++----------------------- 1 file changed, 108 insertions(+), 99 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index 88a1b6e..6ab2991 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -1,6 +1,6 @@ import usb.core, usb.util import json, struct, bitstruct, sys, threading, time -from abc import ABC, abstractmethod +from abc import ABC from array import array from enum import Enum @@ -8,36 +8,33 @@ VENDOR_ID=9463 PRODUCT_ID=8708 -#Structs from Seneye sample C++ code -#https://github.com/seneye/SUDDriver/blob/master/Cpp/sud_data.h -ENDIAN = "<" +# Based on the structs from Seneye sample C++ code +# https://github.com/seneye/SUDDriver/blob/master/Cpp/sud_data.h -#[unused], Kelvin, x, y, Par, Lux, PUR, [unused] -B_LIGHTMETER = "8s3i2IBc" +# little-endian +ENDIAN = "<" -#Flags, [unused], PH, NH3, Temp, [unused] -SUDREADING_VALUES = "4Hi16s" + B_LIGHTMETER +# [unused], Kelvin, x, y, Par, Lux, PUR +B_LIGHTMETER = "11s3i2IB" -#Header, cmd, Timestamp -SUDREADING = ENDIAN + "2sI" + SUDREADING_VALUES +# Flags, [unused], PH, NH3, Temp, [unused] +SUDREADING_VALUES = "4Hi13s" + B_LIGHTMETER -#Header, cmd, IsKelvin -SUDLIGHTMETER = ENDIAN + "2sI" + B_LIGHTMETER +# Header, cmd, Timestamp +SUDREADING = ENDIAN + "2sI" + SUDREADING_VALUES + "c" +# Header, cmd, IsKelvin +SUDLIGHTMETER = ENDIAN + "2s?" + B_LIGHTMETER + "29s" -#These aren't specified as a struct but I'm treating them as if they were -#Header, Cmd, ACK +# These are not specified as a struct, in the original C++ source RESPONSE = ENDIAN + "2s?" - GENERIC_RESPONSE = RESPONSE + "61s" - HELLOSUD_RESPONSE = RESPONSE + "BH58s" # WIP - Decoding the flags -#[unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, -#Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] +# [unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] SUDREADING_FLAGS = "u2b1b1b1u2u2u2b1b1u3" class Command(Enum): @@ -64,7 +61,7 @@ def __init__(self, raw_data, read_def): expected_values = read_def.return_values.split(",") if length != len(expected_values): - raise ValueError("Returned data length doesn't match expected data length") + raise ValueError("Returned parameter number doesn't match expected return parameter number") for i in range(0, length): @@ -76,7 +73,7 @@ def validation_bytes(self): return self._validation_bytes -class InteractiveModeResponse(BaseResponse): +class Response(BaseResponse): def __init__(self, raw_data, read_def): @@ -91,7 +88,7 @@ def ack(self): return self._ack -class EnterInteractiveResponse(InteractiveModeResponse): +class EnterInteractiveResponse(Response): def __init__(self, raw_data, read_def): self._device_type = None @@ -128,6 +125,13 @@ def __init__(self, raw_data, read_def): 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) @@ -135,6 +139,15 @@ def __init__(self, raw_data, read_def): def is_light_reading(self): return self._validation_bytes == COMMAND_DEFINITIONS[Command.LIGHT_READING].reading_definitions[0].validator + @property + def is_kelvin(self): + + if is_light_reading: + return self._is_kelvin + else: + #Need to read this from flags, not implemented yet + return None + @property def timestamp(self): return self._timestamp @@ -155,6 +168,30 @@ def temperature(self): def flags(self): return self._flags + @property + def kelvin(self): + return self._kelvin + + @property + def kelvin_x(self): + return self._kelvin_x + + @property + def kelvin_y(self): + return self._kelvin_y + + @property + def par(self): + return self._par + + @property + def lux(self): + return self._lux + + @property + def pur(self): + return self._pur + class CommandDefinition: @@ -176,11 +213,12 @@ def read_definitions(self): class ReadDefinition: - def __init__(self, parse_str, validator, return_values): + def __init__(self, parse_str, validator, return_values, return_type): self._validator = validator self._parse_str = parse_str self._return_values = return_values + self._return_type = return_type @property def validator(self): @@ -197,7 +235,14 @@ def return_values(self): return self._return_values -SENSOR_RETURN_VALUES = "validation_bytes,timestamp,flags,unused,ph,nh3,temperature,unused,unused,kelvin,kelvin_x,kelvin_y,par,lux,pur,unused" + @property + def return_type(self): + + return self._return_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" @@ -206,32 +251,37 @@ def return_values(self): ReadDefinition( GENERIC_RESPONSE, array('B', [0x88,0x02]), - GENERIC_RETURN_VALUES), + GENERIC_RETURN_VALUES, + Response), ReadDefinition( SUDREADING, array('B', [0x00, 0x01]), - SENSOR_RETURN_VALUES) + SENSOR_RETURN_VALUES, + SensorReadingResponse) ]), Command.LIGHT_READING: CommandDefinition(None, [ ReadDefinition( SUDLIGHTMETER, array('B', [0x00, 0x02]), - SENSOR_RETURN_VALUES) + LIGHT_SENSOR_RETURN_VALUES, + SensorReadingResponse) ]), Command.ENTER_INTERACTIVE_MODE: CommandDefinition("HELLOSUD", [ ReadDefinition( HELLOSUD_RESPONSE, array('B', [0x88, 0x01]), - HELLOSUD_RETURN_VALUES) + HELLOSUD_RETURN_VALUES, + EnterInteractiveResponse) ]), Command.LEAVE_INTERACTIVE_MODE: CommandDefinition("BYESUD", [ ReadDefinition( GENERIC_RESPONSE, array('B', [0x77, 0x01]), # Differs from documented response - GENERIC_RETURN_VALUES) + GENERIC_RETURN_VALUES, + Response) ]) } @@ -289,36 +339,35 @@ def _read(self, packet_size = None): return self._instance.read(self._ep_in, packet_size) - def close(self): - - # 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() + def _data_to_class(data, rdef): + + pass + def get_data(self, cmd, timeout = 10000): - d = COMMAND_DEFINITIONS[cmd] + cdef = COMMAND_DEFINITIONS[cmd] - # TODO Operation can timeout here, need to add sme error handling - if d.cmd_str is not None: - self._write(d.cmd_str) + if cdef.cmd_str is not None: + self._write(cdef.cmd_str) start = time.time() - for rdef in d.read_definitions: + # Preserve data and rdef, to generate the return value + data = None + rdef = None + + + for rdef in cdef.read_definitions: if __debug__: print("validator: {0}".format(rdef.validator)) - result = None + # Re-set while, if there are multiple read defs + data = None - while not result: + while not data: try: r = self._read() @@ -326,67 +375,27 @@ def get_data(self, cmd, timeout = 10000): print("Validation bytes: {0}".format(r[0:2])) if r[0:2] == rdef.validator: - result = r + data = r if __debug__: - print("Result: {0}".format(r)) + print("Result: {0}".format(data)) except: pass if ((time.time() - start) * 1000) > timeout: - - if __debug__: - print("Operation timed out") - - return None - - # TODO Add validation of result and add internal flag for interactive/non-interactive mode - - return result - - -class SUDMonitor: - - def __init__(self, device): - - self._cmd = None - self._stop = True - - self._device = device - - - def monitor_thread(self): - - while not self._stop: - - try: - if self._cmd: - self._device._write(self._cmd) - self._cmd = None + raise TimeoutError("Operation timed out reading response.") - print(self._device._read()) - except: - pass - - - def run(self): - - self._stop = False - - t = threading.Thread(target=self.monitor_thread) - t.start() - - while not self._stop: - cmd = input("cmd (r|h|b|q):") - - if cmd == "r": - self._cmd = "READING" - elif cmd == "h": - self._cmd = "HELLOSUD" - elif cmd == "b": - self._cmd = "BYESUD" - elif cmd == "q": - self._stop = True + return rdef.return_type(data, rdef) + def close(self): + + # 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() From 538d605ca49964fec4cf9910be602c48f8bf2b27 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sat, 23 Feb 2019 20:21:39 +1100 Subject: [PATCH 07/14] Updating method names --- pyseneye/sud.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index 6ab2991..5e750cc 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -339,13 +339,7 @@ def _read(self, packet_size = None): return self._instance.read(self._ep_in, packet_size) - def _data_to_class(data, rdef): - - pass - - - - def get_data(self, cmd, timeout = 10000): + def action(self, cmd, timeout = 10000): cdef = COMMAND_DEFINITIONS[cmd] From c3d8c861347a14a4d25063685bbdfe7d703c678e Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 03:39:01 +1100 Subject: [PATCH 08/14] Cleaning up code, getting ready for release, adding docs --- README.md | 44 ++++++- README.rst | 56 ++++++++ docs/Makefile | 19 +++ docs/conf.py | 196 ++++++++++++++++++++++++++++ docs/index.rst | 21 +++ docs/make.bat | 35 +++++ docs/pyseneye.rst | 22 ++++ pyseneye/__init__.py | 18 +++ pyseneye/sud.py | 207 ++++++++++++++++++------------ pyseneye/test/test_integration.py | 88 +++++++++++++ requirements.txt | 3 + setup.py | 80 ++++++++++++ tox.ini | 11 ++ 13 files changed, 720 insertions(+), 80 deletions(-) create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/pyseneye.rst create mode 100644 pyseneye/__init__.py create mode 100644 pyseneye/test/test_integration.py create mode 100644 setup.py create mode 100644 tox.ini diff --git a/README.md b/README.md index e07de23..4e6f4f9 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, Command +>>> d = SUDevice() +``` + +Once the class is initialised you can put the Seneye into interactive mode and then retrieve sensor readings. + +```python +>>> d.action(Command.ENTER_INTERACTIVE_MODE) +>>> s = d.action(Command.SENSOR_READING) +>>> s.ph +8.16 +>>> s.nh3 +0.007 +>>> s.temperature +25.125 +>>> d.action(Command.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..894b8db --- /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, Command + >>> d = SUDevice() + +Once the class is initialised you can put the Seneye into interactive +mode and then retrieve sensor readings. + +.. code:: python + + >>> d.action(Command.ENTER_INTERACTIVE_MODE) + >>> s = d.action(Command.SENSOR_READING) + >>> s.ph + 8.16 + >>> s.nh3 + 0.007 + >>> s.temperature + 25.125 + >>> d.action(Command.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..3bc580c --- /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 = 'alabaster' + +# 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 index 5e750cc..2388fc3 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -1,12 +1,33 @@ -import usb.core, usb.util -import json, struct, bitstruct, sys, threading, time +# +# 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 -#used to identify the undelying USB device -VENDOR_ID=9463 -PRODUCT_ID=8708 +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 @@ -33,11 +54,24 @@ HELLOSUD_RESPONSE = RESPONSE + "BH58s" -# WIP - Decoding the flags -# [unused], InWater, SlideNotFitted, SlideExpired, StateT, StatePH, StateNH3, Error, IsKelvin, [unused], PH, NH3, Temperature, [unused] -SUDREADING_FLAGS = "u2b1b1b1u2u2u2b1b1u3" +# 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 Command(Enum): + """Commands that can be passed to SUDevice.action().""" SENSOR_READING = 0 ENTER_INTERACTIVE_MODE = 1 @@ -46,49 +80,77 @@ class Command(Enum): class DeviceType(Enum): + """Differnent type of sensor devices.""" HOME = 0 POND = 1 REEF = 3 -class BaseResponse(ABC): - +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") + 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): + """The 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): self._device_type = None @@ -107,19 +169,20 @@ def device_type(self): @property def version(self): - v = self._version + ver = self._version - major = int(v / 10000) - minor = int((v / 100) % 100) - rev = v % 100 + 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.""" def __init__(self, raw_data, read_def): - + self._timestamp = 0 self._ph = 0 self._nh3 = 0 @@ -137,23 +200,26 @@ def __init__(self, raw_data, read_def): @property def is_light_reading(self): - return self._validation_bytes == COMMAND_DEFINITIONS[Command.LIGHT_READING].reading_definitions[0].validator + + rdef = COMMAND_DEFINITIONS[Command.LIGHT_READING].read_definitions[0] + + return self._validation_bytes == rdef.validator @property def is_kelvin(self): - if is_light_reading: + if self.is_light_reading: return self._is_kelvin - else: - #Need to read this from flags, not implemented yet - return None + + # Need to read this from flags, not implemented yet + return None @property def timestamp(self): return self._timestamp @property - def ph(self): + def ph(self): # pylint:disable=C0103 return self._ph/100 @property @@ -194,6 +260,7 @@ def pur(self): class CommandDefinition: + """Definition for command and expected responses.""" def __init__(self, cmd_str, rdefs): @@ -212,6 +279,7 @@ def read_definitions(self): class ReadDefinition: + """Definition of expected response, including validation and parsing.""" def __init__(self, parse_str, validator, return_values, return_type): @@ -240,21 +308,17 @@ def return_type(self): return self._return_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" +# Concrete definitions of all messages commands/messages we can process. COMMAND_DEFINITIONS = { Command.SENSOR_READING: CommandDefinition("READING", [ ReadDefinition( - GENERIC_RESPONSE, - array('B', [0x88,0x02]), + GENERIC_RESPONSE, + array('B', [0x88, 0x02]), GENERIC_RETURN_VALUES, - Response), + Response), ReadDefinition( - SUDREADING, + SUDREADING, array('B', [0x00, 0x01]), SENSOR_RETURN_VALUES, SensorReadingResponse) @@ -262,15 +326,15 @@ def return_type(self): Command.LIGHT_READING: CommandDefinition(None, [ ReadDefinition( - SUDLIGHTMETER, + SUDLIGHTMETER, array('B', [0x00, 0x02]), LIGHT_SENSOR_RETURN_VALUES, SensorReadingResponse) ]), - + Command.ENTER_INTERACTIVE_MODE: CommandDefinition("HELLOSUD", [ ReadDefinition( - HELLOSUD_RESPONSE, + HELLOSUD_RESPONSE, array('B', [0x88, 0x01]), HELLOSUD_RETURN_VALUES, EnterInteractiveResponse) @@ -278,8 +342,8 @@ def return_type(self): Command.LEAVE_INTERACTIVE_MODE: CommandDefinition("BYESUD", [ ReadDefinition( - GENERIC_RESPONSE, - array('B', [0x77, 0x01]), # Differs from documented response + GENERIC_RESPONSE, + array('B', [0x77, 0x01]), # Differs from documented response GENERIC_RETURN_VALUES, Response) ]) @@ -287,9 +351,10 @@ def return_type(self): class SUDevice: + """Encapsulates a Seneye USB Device and it's capabilities.""" def __init__(self): - + dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) if dev is None: @@ -301,95 +366,79 @@ def __init__(self): dev.set_configuration() usb.util.claim_interface(dev, 0) cfg = dev.get_active_configuration() - intf = cfg[(0,0)] - + 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) + 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) + 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): - 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): + def action(self, cmd, timeout=10000): cdef = COMMAND_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: - - if __debug__: - print("validator: {0}".format(rdef.validator)) # Re-set while, if there are multiple read defs - data = None - + data = None + while not data: try: - r = self._read() - - if __debug__: - print("Validation bytes: {0}".format(r[0:2])) - - if r[0:2] == rdef.validator: - data = r - - if __debug__: - print("Result: {0}".format(data)) - except: + 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): - + # 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..15cdda1 --- /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, Command, DeviceType, Response, EnterInteractiveResponse, SensorReadingResponse + +# Requires a device plugged in, currently. + +def init_device(): + + time.sleep(5) + + d = SUDevice() + r = d.action(Command.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(Command.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(Command.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(Command.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 index a1b2c3d..96a46c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ 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 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 From 46c5a0ee3ce4a34a5f496f3f75241563894fb1a0 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 12:22:15 +1100 Subject: [PATCH 09/14] Added more documentation --- pyseneye/sud.py | 180 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 176 insertions(+), 4 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index 2388fc3..e011bb8 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -40,7 +40,7 @@ B_LIGHTMETER = "11s3i2IB" # Flags, [unused], PH, NH3, Temp, [unused] -SUDREADING_VALUES = "4Hi13s" + B_LIGHTMETER +SUDREADING_VALUES = "2s3Hi13s" + B_LIGHTMETER # Header, cmd, Timestamp SUDREADING = ENDIAN + "2sI" + SUDREADING_VALUES + "c" @@ -153,6 +153,14 @@ class EnterInteractiveResponse(Response): """Received when entering interactive mode. Contains device metadata.""" def __init__(self, raw_data, read_def): + """Initialise enter interactive mode response, including 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 @@ -160,7 +168,11 @@ def __init__(self, raw_data, read_def): @property def device_type(self): + """Gets the device type. + :returns: the device type + :rtype: DeviceType + """ if self._device_type is None: return None @@ -168,7 +180,11 @@ def device_type(self): @property def version(self): + """The firmware version of the device. + :returns: the version + :rtype: str + """ ver = self._version major = int(ver / 10000) @@ -182,11 +198,17 @@ class SensorReadingResponse(BaseResponse): """Response which contains all sensor data.""" 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._temperature = None self._flags = None self._is_kelvin = False self._kelvin = 0 @@ -200,14 +222,22 @@ def __init__(self, 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 = COMMAND_DEFINITIONS[Command.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 @@ -216,46 +246,106 @@ def is_kelvin(self): @property def timestamp(self): + """The 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 + """The PH reading from the device. + + :returns: the PH value + :rtype: float + """ return self._ph/100 @property def nh3(self): + """The NH3 reading from the device. + + :returns: the NH3 value + :rtype: float + """ return self._nh3/1000 @property def temperature(self): + """The 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): + """The 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 @@ -263,18 +353,32 @@ class CommandDefinition: """Definition for command and expected responses.""" def __init__(self, cmd_str, rdefs): + """Initialise command 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): + """The command string to write to the device. + :returns: command string + :rtype: str + """ return self._cmd_str @property def read_definitions(self): + """The expected response read definition. + :returns: the read definitions + :rtype: ReadDefinition[] + """ return self._rdefs @@ -282,7 +386,18 @@ 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 @@ -290,22 +405,38 @@ def __init__(self, parse_str, validator, return_values, return_type): @property def validator(self): + """Validation bytes for 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): + """The comma separate list of expected return value names. + :returns: comma separated list + :rtype: str + """ return self._return_values @property def return_type(self): + """The expected response object, a subclass of BaseResponse. + :returns: expected response object + :rtype: BaseResponse subclass + """ return self._return_type @@ -354,6 +485,34 @@ class SUDevice: """Encapsulates a Seneye USB Device and it's capabilities.""" def __init__(self): + """Initialises and opens connection to Seneye USB Device, allowing for + commands and readings to be sent/read from 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 Command.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, Command + >>> d.action(Command.ENTER_INTERACTIVE_MODE) + >>> s = d.action(Command.SENSOR_READING) + >>> s.ph + 8.16 + >>> s.nh3 + 0.007 + >>> s.temperature + 25.125 + >>> d.action(Command.LEAVE_INTERACTIVE_MODE) + >>> d.close() + """ dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) @@ -400,7 +559,20 @@ def _read(self, packet_size=None): 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 Command 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: Command to action + :param timeout: timeout in milliseconds + :type cmd: Command + :type timeout: int + """ cdef = COMMAND_DEFINITIONS[cmd] if cdef.cmd_str is not None: @@ -433,7 +605,7 @@ def action(self, cmd, timeout=10000): 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) From 2ef76d93cf2cec1547b2803d8d7bf0be5ad9530a Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 12:46:26 +1100 Subject: [PATCH 10/14] Finishing up code documentation --- pyseneye/sud.py | 50 +++++++++++++++++++++++++++--------------------- requirements.txt | 2 ++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index e011bb8..aba3351 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -116,7 +116,7 @@ def __init__(self, raw_data, read_def): @property def validation_bytes(self): - """The bytes that are used to validate the message is correct. + """Bytes that are used to validate the message is correct. :returns: bytes used for validation :rtype: array('B', [2]) @@ -153,8 +153,9 @@ class EnterInteractiveResponse(Response): """Received when entering interactive mode. Contains device metadata.""" def __init__(self, raw_data, read_def): - """Initialise enter interactive mode response, including device type - and version. + """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 @@ -168,7 +169,7 @@ def __init__(self, raw_data, read_def): @property def device_type(self): - """Gets the device type. + """Get the device type. :returns: the device type :rtype: DeviceType @@ -180,7 +181,7 @@ def device_type(self): @property def version(self): - """The firmware version of the device. + """Firmware version of the device. :returns: the version :rtype: str @@ -197,6 +198,9 @@ def version(self): 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. @@ -233,7 +237,7 @@ def is_light_reading(self): @property def is_kelvin(self): - """Is light reading on kelvin line: https://tinyurl.com/yy2wtaz5 + """Is light reading on kelvin line: https://tinyurl.com/yy2wtaz5. :returns: True if on kelvin line, False if not :rtype: bool @@ -246,7 +250,8 @@ def is_kelvin(self): @property def timestamp(self): - """The time the reading was taken at. + """Time the reading was taken at. + (only available for sensor readings) :returns: Unix epoch time @@ -256,7 +261,7 @@ def timestamp(self): @property def ph(self): # pylint:disable=C0103 - """The PH reading from the device. + """PH reading from the device. :returns: the PH value :rtype: float @@ -265,7 +270,7 @@ def ph(self): # pylint:disable=C0103 @property def nh3(self): - """The NH3 reading from the device. + """NH3 reading from the device. :returns: the NH3 value :rtype: float @@ -274,7 +279,7 @@ def nh3(self): @property def temperature(self): - """The temperature reading from the device. + """Temperature reading from the device. :returns: the temperature :rtype: float @@ -292,7 +297,7 @@ def flags(self): @property def kelvin(self): - """The kelvin value of the light reading. + """Kelvin value of the light reading. :returns: the kelvin value :rtype: int @@ -301,7 +306,7 @@ def kelvin(self): @property def kelvin_x(self): - """X co-ordinate on the CIE colourspace https://tinyurl.com/yy2wtaz5 + """X co-ordinate on the CIE colourspace https://tinyurl.com/yy2wtaz5. Limited to colors that are near the kelvin line. Check with is_kelvin. @@ -312,7 +317,7 @@ def kelvin_x(self): @property def kelvin_y(self): - """Y co-ordinate on the CIE colourspace https://tinyurl.com/yy2wtaz5 + """Y co-ordinate on the CIE colourspace https://tinyurl.com/yy2wtaz5. Limited to colors that are near the kelvin line. Check with is_kelvin. @@ -365,7 +370,7 @@ def __init__(self, cmd_str, rdefs): @property def cmd_str(self): - """The command string to write to the device. + """Command string to write to the device. :returns: command string :rtype: str @@ -374,7 +379,7 @@ def cmd_str(self): @property def read_definitions(self): - """The expected response read definition. + """Read definition for expected response. :returns: the read definitions :rtype: ReadDefinition[] @@ -405,7 +410,7 @@ def __init__(self, parse_str, validator, return_values, return_type): @property def validator(self): - """Validation bytes for expected read. + """Bytes that are used for validation of expected read. :returns: validation bytes :rtype: array('B', [2]) @@ -423,7 +428,7 @@ def parse_str(self): @property def return_values(self): - """The comma separate list of expected return value names. + """Comma separate list of expected return value names. :returns: comma separated list :rtype: str @@ -432,7 +437,7 @@ def return_values(self): @property def return_type(self): - """The expected response object, a subclass of BaseResponse. + """Subclass of BaseResponse, the expected response object. :returns: expected response object :rtype: BaseResponse subclass @@ -485,8 +490,10 @@ class SUDevice: """Encapsulates a Seneye USB Device and it's capabilities.""" def __init__(self): - """Initialises and opens connection to Seneye USB Device, allowing for - commands and readings to be sent/read from the Seneye device. + """Initialise and open connection to Seneye USB Device. + + Allowing for commands and readings to be sent/read from the Seneye + device. .. note:: When finished SUDevice.close() should be called, to free the USB device, otherwise subsequent calls may fail. @@ -513,7 +520,6 @@ def __init__(self): >>> d.action(Command.LEAVE_INTERACTIVE_MODE) >>> d.close() """ - dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) if dev is None: @@ -605,7 +611,7 @@ def action(self, cmd, timeout=10000): return rdef.return_type(data, rdef) def close(self): - """Close connection to USB device and clean up instance""" + """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) diff --git a/requirements.txt b/requirements.txt index 96a46c3..ca21477 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ 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 From 2b9a9616b087456c891971ff13a082c6ceca2a99 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 12:51:31 +1100 Subject: [PATCH 11/14] Fixing error from integration test --- pyseneye/sud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyseneye/sud.py b/pyseneye/sud.py index aba3351..9b5a28a 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -212,7 +212,7 @@ def __init__(self, raw_data, read_def): self._timestamp = 0 self._ph = 0 self._nh3 = 0 - self._temperature = None + self._temperature = 0 self._flags = None self._is_kelvin = False self._kelvin = 0 From 21c791c095af4655d74b2cc588aa738777e70d23 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 13:06:45 +1100 Subject: [PATCH 12/14] Updating docs config --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 3bc580c..28de611 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,7 +89,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +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 From c50c289c5975769fa2933b050890bf6cc107be5d Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 13:22:49 +1100 Subject: [PATCH 13/14] Changing Command to Action across the SUD module, to make it more consistant --- README.md | 8 +++--- README.rst | 8 +++--- pyseneye/sud.py | 45 +++++++++++++++---------------- pyseneye/test/test_integration.py | 10 +++---- 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 4e6f4f9..c2417f2 100644 --- a/README.md +++ b/README.md @@ -16,22 +16,22 @@ 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, Command +>>> 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(Command.ENTER_INTERACTIVE_MODE) ->>> s = d.action(Command.SENSOR_READING) +>>> 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(Command.LEAVE_INTERACTIVE_MODE) +>>> d.action(Action.LEAVE_INTERACTIVE_MODE) >>> d.close() ``` diff --git a/README.rst b/README.rst index 894b8db..2357805 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ complete you can import the SUDevice class and connect to your device. .. code:: python - >>> from pyseneye.sud import SUDDevice, Command + >>> from pyseneye.sud import SUDDevice, Action >>> d = SUDevice() Once the class is initialised you can put the Seneye into interactive @@ -30,15 +30,15 @@ mode and then retrieve sensor readings. .. code:: python - >>> d.action(Command.ENTER_INTERACTIVE_MODE) - >>> s = d.action(Command.SENSOR_READING) + >>> 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(Command.LEAVE_INTERACTIVE_MODE) + >>> d.action(Action.LEAVE_INTERACTIVE_MODE) >>> d.close() You need access to the USB device, so these calls may require elevated diff --git a/pyseneye/sud.py b/pyseneye/sud.py index 9b5a28a..c2be0c3 100644 --- a/pyseneye/sud.py +++ b/pyseneye/sud.py @@ -70,8 +70,8 @@ GENERIC_RETURN_VALUES = "validation_bytes,ack,unused" -class Command(Enum): - """Commands that can be passed to SUDevice.action().""" +class Action(Enum): + """Actions that can be passed to SUDevice.action().""" SENSOR_READING = 0 ENTER_INTERACTIVE_MODE = 1 @@ -231,7 +231,7 @@ def is_light_reading(self): :returns: True if a light reading, False if a sensor reading. :rtype: bool """ - rdef = COMMAND_DEFINITIONS[Command.LIGHT_READING].read_definitions[0] + rdef = ACTION_DEFINITIONS[Action.LIGHT_READING].read_definitions[0] return self._validation_bytes == rdef.validator @@ -354,11 +354,11 @@ def pur(self): return self._pur -class CommandDefinition: - """Definition for command and expected responses.""" +class ActionDefinition: + """Definition for action and expected responses.""" def __init__(self, cmd_str, rdefs): - """Initialise command definition. + """Initialise action definition. :param cmd_str: the command string, to send to the device. :param rdefs: the definition of the expected responses @@ -445,9 +445,9 @@ def return_type(self): return self._return_type -# Concrete definitions of all messages commands/messages we can process. -COMMAND_DEFINITIONS = { - Command.SENSOR_READING: CommandDefinition("READING", [ +# Concrete definitions of all actions we can take.. +ACTION_DEFINITIONS = { + Action.SENSOR_READING: ActionDefinition("READING", [ ReadDefinition( GENERIC_RESPONSE, array('B', [0x88, 0x02]), @@ -460,7 +460,7 @@ def return_type(self): SensorReadingResponse) ]), - Command.LIGHT_READING: CommandDefinition(None, [ + Action.LIGHT_READING: ActionDefinition(None, [ ReadDefinition( SUDLIGHTMETER, array('B', [0x00, 0x02]), @@ -468,7 +468,7 @@ def return_type(self): SensorReadingResponse) ]), - Command.ENTER_INTERACTIVE_MODE: CommandDefinition("HELLOSUD", [ + Action.ENTER_INTERACTIVE_MODE: ActionDefinition("HELLOSUD", [ ReadDefinition( HELLOSUD_RESPONSE, array('B', [0x88, 0x01]), @@ -476,7 +476,7 @@ def return_type(self): EnterInteractiveResponse) ]), - Command.LEAVE_INTERACTIVE_MODE: CommandDefinition("BYESUD", [ + Action.LEAVE_INTERACTIVE_MODE: ActionDefinition("BYESUD", [ ReadDefinition( GENERIC_RESPONSE, array('B', [0x77, 0x01]), # Differs from documented response @@ -492,14 +492,13 @@ class SUDevice: def __init__(self): """Initialise and open connection to Seneye USB Device. - Allowing for commands and readings to be sent/read from the Seneye - 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 Command.ENTER_INTERACTIVE_MODE to do this. + 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. @@ -508,16 +507,16 @@ def __init__(self): occur while trying to connect to USB device. :Example: - >>> from pyseneye.sud import SUDevice, Command - >>> d.action(Command.ENTER_INTERACTIVE_MODE) - >>> s = d.action(Command.SENSOR_READING) + >>> 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(Command.LEAVE_INTERACTIVE_MODE) + >>> d.action(Action.LEAVE_INTERACTIVE_MODE) >>> d.close() """ dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) @@ -567,19 +566,19 @@ def _read(self, packet_size=None): def action(self, cmd, timeout=10000): """Perform action on device. - The available actions are specified by the Command Enum. These actions + 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: Command to action + :param cmd: Action to action :param timeout: timeout in milliseconds - :type cmd: Command + :type cmd: Action :type timeout: int """ - cdef = COMMAND_DEFINITIONS[cmd] + cdef = ACTION_DEFINITIONS[cmd] if cdef.cmd_str is not None: self._write(cdef.cmd_str) diff --git a/pyseneye/test/test_integration.py b/pyseneye/test/test_integration.py index 15cdda1..14a93f5 100644 --- a/pyseneye/test/test_integration.py +++ b/pyseneye/test/test_integration.py @@ -2,7 +2,7 @@ import time from unittest.mock import Mock, patch -from pyseneye.sud import SUDevice, Command, DeviceType, Response, EnterInteractiveResponse, SensorReadingResponse +from pyseneye.sud import SUDevice, Action, DeviceType, Response, EnterInteractiveResponse, SensorReadingResponse # Requires a device plugged in, currently. @@ -11,7 +11,7 @@ def init_device(): time.sleep(5) d = SUDevice() - r = d.action(Command.ENTER_INTERACTIVE_MODE) + r = d.action(Action.ENTER_INTERACTIVE_MODE) return d, r @@ -33,7 +33,7 @@ def test_SUDevice_leave_interactive_mode(): d, r = init_device() assert r.ack == True - r = d.action(Command.LEAVE_INTERACTIVE_MODE) + r = d.action(Action.LEAVE_INTERACTIVE_MODE) assert r.ack == True assert r.__class__ == Response @@ -45,7 +45,7 @@ def test_SUDevice_get_light_reading(): d, r = init_device() assert r.ack == True - r = d.action(Command.LIGHT_READING) + r = d.action(Action.LIGHT_READING) assert r.__class__ == SensorReadingResponse assert r.flags == None assert r.is_light_reading == True @@ -67,7 +67,7 @@ def test_SUDevice_get_sensor_reading(): d, r = init_device() assert r.ack == True - r = d.action(Command.SENSOR_READING) + r = d.action(Action.SENSOR_READING) assert r.__class__ == SensorReadingResponse assert r.flags != None assert r.is_light_reading == False From 938f0345cb5acc115c3c401fadd7b344172a93db Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sun, 24 Feb 2019 13:27:32 +1100 Subject: [PATCH 14/14] Updating requirements.txt with twine --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index ca21477..5146ded 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,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