From: Philipp Klaus Date: Tue, 30 Jun 2015 13:06:40 +0000 (+0200) Subject: adding the opus20 package X-Git-Url: https://jspc29.x-matter.uni-frankfurt.de/git/?a=commitdiff_plain;h=821414fb74a69a2f041124839232cb721fb214a9;p=labtools.git adding the opus20 package --- diff --git a/opus20/MANIFEST.in b/opus20/MANIFEST.in new file mode 100644 index 0000000..2c31157 --- /dev/null +++ b/opus20/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include opus20/webapp/views * +recursive-include opus20/webapp/static * diff --git a/opus20/README.md b/opus20/README.md new file mode 100644 index 0000000..9c1afdf --- /dev/null +++ b/opus20/README.md @@ -0,0 +1,89 @@ + +### opus20 - a Python interface to the OPUS20 + +This is a *opus20*, a Python software to query the temperature / +humidity / air pressure logging device OPUS20 produced by Lufft. + +#### Requirements + +*opus20* depends (only) on Python version 3.3+. +I thought about backporting it to Python 2.7+ but it's not done so far. + +The web interface requires a couple of Python packages: + + pip install jinja2 bottle matplotlib pandas numpy + +Installing matplotlib may also require you to install +the python development package (for Python 3). + +#### Installing + +This package can be installed via pip: + + pip install --upgrade https://github.com/pklaus/opus20/archive/master.zip + +#### Usage + +The Python package installs a command line tool to query the device +for current values. It's called `opus20_cli`. + +Here is how to get a list of all available *channels* from the device: + + philipp@lion:~> opus20_cli 192.168.1.55 list + Channel 100 (0x0064): CUR temperature unit: °C offset: ±10.0 + Channel 120 (0x0078): MIN temperature unit: °C offset: ±10.0 + Channel 140 (0x008C): MAX temperature unit: °C offset: ±10.0 + Channel 160 (0x00A0): AVG temperature unit: °C offset: ±10.0 + Channel 105 (0x0069): CUR temperature unit: °F offset: 0.0 + Channel 125 (0x007D): MIN temperature unit: °F offset: 0.0 + Channel 145 (0x0091): MAX temperature unit: °F offset: 0.0 + Channel 165 (0x00A5): AVG temperature unit: °F offset: 0.0 + Channel 200 (0x00C8): CUR relative humidity unit: % offset: ±30.0 + Channel 220 (0x00DC): MIN relative humidity unit: % offset: ±30.0 + Channel 240 (0x00F0): MAX relative humidity unit: % offset: ±30.0 + Channel 260 (0x0104): AVG relative humidity unit: % offset: ±30.0 + Channel 205 (0x00CD): CUR absolute humidity unit: g/m³ offset: 0.0 + Channel 225 (0x00E1): MIN absolute humidity unit: g/m³ offset: 0.0 + Channel 245 (0x00F5): MAX absolute humidity unit: g/m³ offset: 0.0 + Channel 265 (0x0109): AVG absolute humidity unit: g/m³ offset: 0.0 + Channel 110 (0x006E): CUR dewpoint unit: °C offset: 0.0 + Channel 130 (0x0082): MIN dewpoint unit: °C offset: 0.0 + Channel 150 (0x0096): MAX dewpoint unit: °C offset: 0.0 + Channel 170 (0x00AA): AVG dewpoint unit: °C offset: 0.0 + Channel 115 (0x0073): CUR dewpoint unit: °F offset: 0.0 + Channel 135 (0x0087): MIN dewpoint unit: °F offset: 0.0 + Channel 155 (0x009B): MAX dewpoint unit: °F offset: 0.0 + Channel 175 (0x00AF): AVG dewpoint unit: °F offset: 0.0 + Channel 10020 (0x2724): CUR battery voltage unit: V offset: 0.0 + Channel 10040 (0x2738): MIN battery voltage unit: V offset: 0.0 + Channel 10060 (0x274C): MAX battery voltage unit: V offset: 0.0 + Channel 10080 (0x2760): AVG battery voltage unit: V offset: 0.0 + + +Asking for the value of a channel works like this: + + philipp@lion:~> opus20_cli 192.168.1.55 get 0x0064 + 24.712 + +You can also download the values stored on the device and store them in a file: + + philipp@lion:~> opus20_cli --loglevel INFO localhost download log_data.pickle + INFO:opus20.opus20:Connected to device with ID: EC9C0A06B183 + INFO:opus_cli:script running time (net): 1.208517 seconds. + philipp@lion:~> + +#### Author + +* (c) 2015, Philipp Klaus + + Ported the software to Python, extended and packaged it. +* (c) 2012, [Ondics GmbH](http://www.ondics.de) + + The author of the [original scripts][l2p_bash_scripts] (written as Bash scripts + netcat & gawk) + +#### License + +GPLv3 + +[l2p_bash_scripts]: https://github.com/ondics/lufft-l2p-script-collection + diff --git a/opus20/gitignore b/opus20/gitignore new file mode 100644 index 0000000..d1336e4 --- /dev/null +++ b/opus20/gitignore @@ -0,0 +1,10 @@ +.gitignore +__pycache__ +ERRORS +communication_protocols +github +test.py +*.pyc +lufft-l2p-script-collection +log_data.pickle +plot_log_data.py diff --git a/opus20/opus20/__init__.py b/opus20/opus20/__init__.py new file mode 100644 index 0000000..5a35af4 --- /dev/null +++ b/opus20/opus20/__init__.py @@ -0,0 +1,8 @@ + +from .opus20 import Opus20, Frame, PickleStore +from .opus20 import CHANNEL_SPEC as OPUS20_CHANNEL_SPEC +from .opus20 import Opus20Exception, Opus20ConnectionException +from .opus20 import Object + +from .webapp import PlotWebServer + diff --git a/opus20/opus20/opus20.py b/opus20/opus20/opus20.py new file mode 100644 index 0000000..482c692 --- /dev/null +++ b/opus20/opus20/opus20.py @@ -0,0 +1,700 @@ +#!/usr/bin/env python + +import socket +import select +import pdb +import struct +import time +import logging +from datetime import datetime, timedelta +import pickle + +logger = logging.getLogger(__name__) + +class Opus20(object): + + def __init__(self, host, port=52015, timeout=5.): + + self.s = None + + self.host = host + self.port = port + self.timeout = timeout + + self.request_supported_channels() + self.request_device_status() + + def connect(self): + try: + self.s = socket.create_connection((self.host, self.port), self.timeout) + except (ConnectionRefusedError, socket.gaierror) as e: + raise Opus20ConnectionException("Connection to host {} could not be established: {}".format(self.host, e)) + + def disconnect(self): + try: + # 0 = done receiving, 1 = done sending, 2 = both + self.s.shutdown(2) + except: + pass + try: + self.s.close() + except: + pass + self.s = None + + @property + def connected(self): + if not self.s: return False + try: + ready_to_read, ready_to_write, in_error = \ + select.select([self.s,], [self.s,], [], 5) + except select.error: + self.disconnect() + return False + return True + + def query_frame(self, frame): + assert type(frame) == Frame + return self.query_bytes(frame.data) + + def query_bytes(self, data : bytes): + if not self.connected: self.connect() + logger.debug("Sending the following {} bytes now: {}".format(len(data), hex_formatter(data))) + frame = None + num_tries = 3 + while num_tries: + try: + self.s.sendall(data) + answer = self.s.recv(1024) + frame = Frame(answer) + frame.validate() + break + except IncompleteDataException: + answer += self.s.recv(1024) + frame = Frame(answer) + frame.validate() + break + except FrameValidationException as e: + logger.warning("The frame couldn't be validated: " + str(e)) + num_tries -= 1 + logger.warning("remaining tries: {}".format(num_tries)) + if not frame.props: raise NameError("Couldn't get a valid answer.") + logger.debug("Received the following {} bytes as answer: {}".format(len(frame.data), hex_formatter(frame.data))) + return frame + + def request_supported_channels(self): + frame = Frame.from_cmd_and_payload(0x31, b"\x16") + answer = self.query_frame(frame) + self.available_channels = answer.available_channels() + + def request_channel_properties(self, channel: int): + query_frame = Frame.from_cmd_and_payload(0x31, b"\x30" + struct.pack('> 8]) + data += cls.EOT + + frame = Frame() + frame.data = data + return frame + + def validate(self): + + data = self.data + + if len(data) < 12: + logger.warning("message incomplete? Expected at least 12 bytes, got {}. ".format(len(data))) + raise IncompleteDataException() + + frame_style = None + offset = 0 + # check header + if data[0:6] == self.HEADER_SHORT: + frame_style = self.SHORT_FRAME + elif data[0:6] == self.HEADER_LONG: + frame_style = self.LONG_FRAME + else: + raise FrameValidationException("l2p-header incorrect: " + str(data[0:6])) + + # length of payload + length = 0 + if frame_style == self.SHORT_FRAME: + length = data[6] + elif frame_style == self.LONG_FRAME: + length = struct.unpack('> 8]) + chksum_found = data[9+offset+length:9+offset+length+2] + if chksum_calc != chksum_found: + logger.warning("Checksum: WRONG!") + raise FrameValidationException("l2p chksum incorrect: {} vs. {}".format(chksum_calc, chksum_found)) + else: + logger.debug("Checksum: OK") + + # eot ok? + if data[11+offset+length] != 0x04: raise FrameValidationException("l2p-eot incorrect") + + props = Object() + props.cmd = cmd + props.verc = verc + props.length = length + props.payload = payload + props.frame_style = frame_style + props.chksum_found = chksum_found + self._props = props + + return True + + @property + def status(self): + return self.props.payload[0] + + def assert_status(self): + # check the status byte + status = self.status + if status == 0x0: + logger.debug("Status: {}".format(STATUS_WORDS[status])) + return True + else: + logger.warning("Status: {}".format(STATUS_WORDS[status])) + msg = 'status not ok: 0x{:02X}'.format(status) + if status in STATUS_WORDS: msg += ' error code: {}'.format(STATUS_WORDS[status]['name']) + else: msg += ' error code: unknown' + raise NameError(msg) + + @property + def props(self): + """ returns an object containing the basic properties of the Frame. + This object is created when calling Frame.validate() . """ + try: + return self._props + except: + raise NameError('Props not available yet. Check Frame.validate() first.') + + + @property + def kind(self): + FRAME_KINDS = [ + # + Object(cmd=0x1E, payload_check=[], payload_length= 0, name='network discovery request'), + Object(cmd=0x1E, payload_check=[], payload_length= 35, name='network discovery answer'), + # + Object(cmd=0x23, payload_check=[], payload_length= 2, name='online single channel request'), + Object(cmd=0x23, payload_check=[0x00,], payload_length= 8, name='online single channel answer', func=self.online_data_request_single), + # + Object(cmd=0x24, payload_check=[0x10,], payload_length= 10, name='initiate log download request'), + Object(cmd=0x24, payload_check=[0x00, 0x10], payload_length= 10, name='initiate log download answer'), + # + Object(cmd=0x24, payload_check=[0x20, 0x01], payload_length= 2, name='log download data request'), + Object(cmd=0x24, payload_check=[0x00, 0x20], payload_length=None, name='log download data answer', func=self.get_log_data), + # + Object(cmd=0x27, payload_check=[], payload_length= 8, name='update time request'), + Object(cmd=0x27, payload_check=[0x00,], payload_length= 1, name='update time answer'), + # + Object(cmd=0x2F, payload_check=[], payload_length= 2, name='online multiple channel request'), + Object(cmd=0x2F, payload_check=[0x00,], payload_length=None, name='online multiple channel answer', func=self.online_data_request_multiple), + # + Object(cmd=0x31, payload_check=[0x16,], payload_length= 1, name='channel list request'), + Object(cmd=0x31, payload_check=[0x00, 0x16,], payload_length=None, name='channel list answer', func=self.available_channels), + # + Object(cmd=0x31, payload_check=[0x17,], payload_length= 1, name='channel group list request'), + Object(cmd=0x31, payload_check=[0x00, 0x17,], payload_length=None, name='channel group list answer'), + # + Object(cmd=0x31, payload_check=[0x30,], payload_length= 3, name='information on specific channel request'), + Object(cmd=0x31, payload_check=[0x00, 0x30,], payload_length= 85, name='information on specific channel answer', func=self.channel_properties), + # + Object(cmd=0x31, payload_check=[0x10,], payload_length= 1, name='advanced status request 0x10 (?)'), + Object(cmd=0x31, payload_check=[0x00, 0x10,], payload_length=None, name='advanced status answer 0x10 (?)'), + # + Object(cmd=0x31, payload_check=[0x13,], payload_length= 1, name='advanced status request 0x13 (?)'), + Object(cmd=0x31, payload_check=[0x00, 0x13,], payload_length=None, name='advanced status answer 0x13 (?)'), + # + Object(cmd=0x31, payload_check=[0x60,], payload_length= 1, name='device status request'), + Object(cmd=0x31, payload_check=[0x00, 0x60,], payload_length= 10, name='device status answer'), + # + Object(cmd=0x44, payload_check=[0x12,], payload_length= 2, name='[r] value range of channel group request'), + Object(cmd=0x44, payload_check=[0x00, 0x12], payload_length= 18, name='[r] value range of channel group answer'), + # + Object(cmd=0x44, payload_check=[0x22,], payload_length= 3, name='[r] enable/disable logging of specific channel request'), + Object(cmd=0x44, payload_check=[0x00, 0x22], payload_length= 5, name='[r] enable/disable logging of specific channel answer'), + Object(cmd=0x45, payload_check=[0x22,], payload_length= 4, name='[w] enable/disable logging of specific channel request'), + Object(cmd=0x45, payload_check=[0x00, 0x22], payload_length= 6, name='[w] enable/disable logging of specific channel answer'), + # + Object(cmd=0x44, payload_check=[0x41,], payload_length= 1, name='[r] measuring/logging interval request'), + Object(cmd=0x44, payload_check=[0x00, 0x41], payload_length= 14, name='[r] measuring/logging interval answer'), + Object(cmd=0x45, payload_check=[0x41,], payload_length= 9, name='[w] measuring/logging interval request'), + Object(cmd=0x45, payload_check=[0x00, 0x41], payload_length= 8, name='[w] measuring/logging interval answer'), + # + Object(cmd=0x44, payload_check=[0x43,], payload_length= 1, name='[r] enable/disable logging request'), + Object(cmd=0x44, payload_check=[0x00, 0x43], payload_length= 3, name='[r] enable/disable logging answer'), + Object(cmd=0x45, payload_check=[0x43,], payload_length= 2, name='[w] enable/disable logging request'), + Object(cmd=0x45, payload_check=[0x00,], payload_length= 1, name='[w] enable/disable logging answer'), + # + Object(cmd=0x46, payload_check=[], payload_length= 0, name='clear log request'), + Object(cmd=0x46, payload_check=[0x00,], payload_length= 1, name='clear log answer'), + # + ## Commands I don't understand right now: + # 31/31 : channel group specific, a 2+1+1+n-byte answer, the n-bytes are counting upwards + # 44/11 : channel group specific, a 2+1+1-byte answer (single flat?) + # 44/13 : channel group specific, a 2+1+1+4-byte answer with a float value of 0.0 + # 44/21 : channel group specific, 2+81-byte answer with mostly 0x00 values + # 44/31 : global, 2+80-byte answer with mostly 0x00 values + # 44/61 : global, a 2+1-byte answer with 0x00 (single flag?) + # 44/62 : global, a 2+1-byte answer with 0x00 (single flag?) + # 44/70 : global, a 2+2-byte answer with 0x00 0x00 + # 44/81 : global, contains the device ID like 31/60 + ] + + props = self.props + + # all frames I have seen so far use verc = 0x10 + if props.verc != 0x10: return None + + for knd in FRAME_KINDS: + if knd.cmd != props.cmd: continue + if knd.payload_length != None and knd.payload_length != len(props.payload): + continue + if len(knd.payload_check) > len(props.payload): continue + payload_matches = True + for i in range(len(knd.payload_check)): + ref_byte = knd.payload_check[i] + check_byte = props.payload[i] + if ref_byte != None and ref_byte != check_byte: + payload_matches = False + break + if payload_matches: return knd + return None + + def available_channels(self): + # cmd="31 10" (which channels are available in device?) + + props = self.props + + assert props.length >= 3, 'message too short for an answer containing the available channels' + assert props.cmd == 0x31 and props.verc == 0x10 and props.payload[1] == 0x16 + self.assert_status() + + logger.debug("Channel Query (31 10 16)") + num_channels = props.payload[2] + logger.debug("Number of available channels: {}".format(num_channels)) + channels = [] + for i in range(num_channels): + # Little Endian 16 bit: + channel = props.payload[3+2*i:3+2*i+2] + channel = channel[0] + (channel[1] << 8 ) + if channel not in CHANNEL_SPEC: + # This is the case for channel 150 = 0x0096 + continue + channels.append(channel) + return channels + + def channel_properties(self): + props = self.props + assert len(props.payload) == 85 + assert props.cmd == 0x31 and props.verc == 0x10 + assert props.payload[0:2] == b"\x00\x30" + channel, group, name, unit, kind, min, max = struct.unpack('= 3, 'message too short for an online data request with a single channel' + assert props.cmd == 0x23 and props.verc == 0x10 + self.assert_status() + logger.debug("Online Data Request (single channel) (23 10)") + channel_value = Frame.read_channel_value(props.payload, 1, length=7, status=self.status) + return channel_value.value + + def online_data_request_multiple(self): + # cmd="2F 10" (online data request, multiple channels) + + props = self.props + + assert props.length >= 3, 'message too short for an online data request with multiple channels' + assert props.cmd == 0x2F and props.verc == 0x10 + self.assert_status() + logger.debug("Online Data Request (multiple channels) (2F 10)") + + offset = 1 + num_channels = props.payload[offset] + logger.debug("Number of Channels={}".format(num_channels)) + offset += 1 + + values = [] + + for i in range(num_channels): + channel_value = Frame.read_channel_value(props.payload, offset) + offset += 1 + channel_value.length + values.append(channel_value.value) + + return values + + def get_log_data(self): + props = self.props + + assert props.length >= 21, 'message too short for a log data message' + assert props.cmd == 0x24 and props.verc == 0x10 and props.payload[0:2] == b"\x00\x20" + + is_final, begin, end, interval, num_blocks = struct.unpack('> 8) ^ crc16_table[ (c ^ ( crc_buff & 0xFF )) ] + return crc_buff + +CHANNEL_SPEC = { + 100: {'name': 'CUR temperature', 'unit': '°C', 'offset' : '±10.0', }, + 120: {'name': 'MIN temperature', 'unit': '°C', 'offset' : '±10.0', }, + 140: {'name': 'MAX temperature', 'unit': '°C', 'offset' : '±10.0', }, + 160: {'name': 'AVG temperature', 'unit': '°C', 'offset' : '±10.0', }, + 105: {'name': 'CUR temperature', 'unit': '°F', 'offset' : '0.0', }, + 125: {'name': 'MIN temperature', 'unit': '°F', 'offset' : '0.0', }, + 145: {'name': 'MAX temperature', 'unit': '°F', 'offset' : '0.0', }, + 165: {'name': 'AVG temperature', 'unit': '°F', 'offset' : '0.0', }, + 110: {'name': 'CUR dewpoint', 'unit': '°C', 'offset' : '0.0', }, + 130: {'name': 'MIN dewpoint', 'unit': '°C', 'offset' : '0.0', }, + 150: {'name': 'MAX dewpoint', 'unit': '°C', 'offset' : '0.0', }, + 170: {'name': 'AVG dewpoint', 'unit': '°C', 'offset' : '0.0', }, + 115: {'name': 'CUR dewpoint', 'unit': '°F', 'offset' : '0.0', }, + 135: {'name': 'MIN dewpoint', 'unit': '°F', 'offset' : '0.0', }, + 155: {'name': 'MAX dewpoint', 'unit': '°F', 'offset' : '0.0', }, + 175: {'name': 'AVG dewpoint', 'unit': '°F', 'offset' : '0.0', }, + 200: {'name': 'CUR relative humidity', 'unit': '%', 'offset' : '±30.0', }, + 220: {'name': 'MIN relative humidity', 'unit': '%', 'offset' : '±30.0', }, + 240: {'name': 'MAX relative humidity', 'unit': '%', 'offset' : '±30.0', }, + 260: {'name': 'AVG relative humidity', 'unit': '%', 'offset' : '±30.0', }, + 205: {'name': 'CUR absolute humidity', 'unit': 'g/m³', 'offset' : '0.0', }, + 225: {'name': 'MIN absolute humidity', 'unit': 'g/m³', 'offset' : '0.0', }, + 245: {'name': 'MAX absolute humidity', 'unit': 'g/m³', 'offset' : '0.0', }, + 265: {'name': 'AVG absolute humidity', 'unit': 'g/m³', 'offset' : '0.0', }, + 300: {'name': 'CUR abs. air pressure', 'unit': 'hPa', 'offset' : '±10.0', }, + 320: {'name': 'MIN abs. air pressure', 'unit': 'hPa', 'offset' : '±10.0', }, + 340: {'name': 'MAX abs. air pressure', 'unit': 'hPa', 'offset' : '±10.0', }, + 360: {'name': 'AVG abs. air pressure', 'unit': 'hPa', 'offset' : '±10.0', }, + 305: {'name': 'CUR abs. air pressure', 'unit': 'hPa', 'offset' : '0.0', }, + 325: {'name': 'MIN abs. air pressure', 'unit': 'hPa', 'offset' : '0.0', }, + 345: {'name': 'MAX abs. air pressure', 'unit': 'hPa', 'offset' : '0.0', }, + 365: {'name': 'AVG abs. air pressure', 'unit': 'hPa', 'offset' : '0.0', }, + 10020: {'name': 'CUR battery voltage', 'unit': 'V', 'offset' : '0.0', }, + 10040: {'name': 'MIN battery voltage', 'unit': 'V', 'offset' : '0.0', }, + 10060: {'name': 'MAX battery voltage', 'unit': 'V', 'offset' : '0.0', }, + 10080: {'name': 'AVG battery voltage', 'unit': 'V', 'offset' : '0.0', }, +} +STATUS_WORDS = { + 0x00: {'name': "OK", 'descr': "command successful"}, + 0x10: {'name': "UNKNOWN_CMD", 'descr': "unknown command"}, + 0x11: {'name': "INVALID_PARAM", 'descr': "invalid parameter"}, + 0x12: {'name': "INVALID_HEADER", 'descr': "invalid header version"}, + 0x13: {'name': "INVALID_VERC", 'descr': "invalid verion of the command"}, + 0x14: {'name': "INVALID_PW", 'descr': "invalid password for command"}, + 0x20: {'name': "READ_ERR", 'descr': "read error"}, + 0x21: {'name': "WRITE_ERR", 'descr': "write error"}, + 0x22: {'name': "TOO_LONG", 'descr': "too long"}, + 0x23: {'name': "INVALID_ADDRESS", 'descr': "invalid address"}, + 0x24: {'name': "INVALID_CHANNEL", 'descr': "invalid channel"}, + 0x25: {'name': "INVALID_CMD", 'descr': "command not possible in this mode"}, + 0x26: {'name': "UNKNOWN_CAL_CMD", 'descr': "unknown calibration command"}, + 0x27: {'name': "CAL_ERROR", 'descr': "calibration error"}, + 0x28: {'name': "BUSY", 'descr': "busy"}, + 0x29: {'name': "LOW_VOLTAGE", 'descr': "low voltage"}, + 0x2A: {'name': "HW_ERROR", 'descr': "hardware error"}, + 0x2B: {'name': "MEAS_ERROR", 'descr': "measurement error"}, + 0x2C: {'name': "INIT_ERROR", 'descr': "device initialization error"}, + 0x2D: {'name': "OS_ERROR", 'descr': "operating system error"}, + 0x30: {'name': "E2_DEFAULT_CONF", 'descr': "error. loading the default configuration."}, + 0x31: {'name': "E2_CAL_ERROR", 'descr': "calibration invalid - measurement impossible"}, + 0x32: {'name': "E2_CRC_CONF_ERR", 'descr': "CRC error. loading the default configuration."}, + 0x33: {'name': "E2_CRC_CAL_ERR", 'descr': "CRC error. calibration invalid - measurement impossible"}, + 0x34: {'name': "ADJ_STEP1", 'descr': "adjustment step 1"}, + 0x35: {'name': "ADJ_OK", 'descr': "adjustment OK"}, + 0x36: {'name': "CHANNEL_OFF", 'descr': "channel deactivated"}, + 0x50: {'name': "VALUE_OVERFLOW", 'descr': "measured value (+offset) is above the set value limit"}, + 0x51: {'name': "VALUE_UNDERFLOW", 'descr': ""}, + 0x52: {'name': "CHANNEL_OVERRANGE", 'descr': "measured value (physical) is above the measurable range (e.g. ADC saturation)"}, + 0x53: {'name': "CHANNEL_UNDERRANGE", 'descr': ""}, + 0x54: {'name': "DATA_ERROR", 'descr': "measurement data is invalid or doesn't exist"}, + 0x55: {'name': "MEAS_UNABLE", 'descr': "measurement impossible - check the environment conditions!"}, + 0x60: {'name': "FLASH_CRC_ERR", 'descr': "CRC error in the values stored in flash memory"}, + 0x61: {'name': "FLASH_WRITE_ERR", 'descr': "error on writing to flash memory"}, + 0x62: {'name': "FLASH_FLOAT_ERR", 'descr': "flash memory contains invalid float values"}, + 0x80: {'name': "FW_RECEIVE_ERR", 'descr': "Error activating firmware flash mode"}, + 0x81: {'name': "CRC_ERR", 'descr': "CRC error"}, + 0x82: {'name': "TIMEOUT_ERR", 'descr': "timeout occured"}, + 0xF0: {'name': "RESERVED", 'descr': "reserved"}, + 0xFF: {'name': "UNKNOWN_ERR", 'descr': "unknown error"}, +} + +class Object(object): + def __init__(self, **kwargs): + for key, val in kwargs.items(): + setattr(self, key, val) + + def __getitem__(self, key): + return getattr(self, key) + + def __repr__(self): + return "Object.from_dict({})".format(self.__dict__) + def __str__(self): + return repr(self) + + @classmethod + def from_dict(cls, d): + o = Object() + for key, val in d: + setattr(o, key, val) + return o + diff --git a/opus20/opus20/webapp/__init__.py b/opus20/opus20/webapp/__init__.py new file mode 100755 index 0000000..2470e20 --- /dev/null +++ b/opus20/opus20/webapp/__init__.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# local deps +from opus20 import Opus20, OPUS20_CHANNEL_SPEC, PickleStore, Object + +# std lib +import logging +import os +import time +from datetime import datetime + +# external deps +from bottle import Bottle, request, response, view, static_file, TEMPLATE_PATH, jinja2_view as view + +logger = logging.getLogger(__name__) + +# Find out where our resource files are located: +try: + from pkg_resources import resource_filename, Requirement + PATH = resource_filename("opus20", "webapp") +except: + PATH = './' + +TEMPLATE_PATH.insert(0, os.path.join(PATH, 'views')) + +clock = time.perf_counter + +class PlotWebServer(Bottle): + + DPI = 72 + TPL_GLOBALS = {} + MIME_MAP = { + 'pdf': 'application/pdf', + 'png': 'image/png', + 'svg': 'image/svg+xml' + } + + def __init__(self, host, log_file, **kwargs): + if 'debug' in kwargs: + self.debug = kwargs['debug'] + del kwargs['debug'] + else: + self.debug = False + self.TPL_GLOBALS['debug_mode'] = self.debug + self.o20 = Opus20(host, **kwargs) + self.o20.disconnect() + self.logfile = log_file + self.ps = PickleStore(log_file) + self._current_values_last_call = -1E13 + self._download_device_data_last_call = -1E13 + super(PlotWebServer, self).__init__() + self.route('/connected/device', callback = self._connected_device) + self.route('/list/devices', callback = self._list_devices) + self.route('/download/', callback = self._download_device_data) + self.route('/plot/_history.', callback = self._plot_history) + self.route('/static/', callback = self._serve_static) + if self.debug: self.route('/debug', callback = self._debug_page) + self.route('/plots', callback = self._plots_page) + self.route('/about', callback = self._about_page) + self.route('/', callback = self._status_page) + + def _atg(self, vals): + """ Add template globals """ + vals.update(self.TPL_GLOBALS) + return vals + + @view('status.jinja2') + def _status_page(self): + return self._atg({'current_values': self.current_values, 'active': 'status'}) + + @view('about.jinja2') + def _about_page(self): + return self._atg({'active': 'about'}) + + @view('plots.jinja2') + def _plots_page(self): + return self._atg({'device_id': self._connected_device(), 'active': 'plots'}) + + @view('debug.jinja2') + def _debug_page(self): + return self._atg({ + 'active': 'debug', + 'debug_dict': { + 'self._current_values_last_call': self._current_values_last_call, + 'self._download_device_data_last_call': self._download_device_data_last_call, + } + }) + + @property + def current_values(self): + """ the current values """ + if clock() - self._current_values_last_call < 2.0: + return self._cached_current_values + self._current_values_last_call = clock() + cur = Object() + values = self.o20.multi_channel_value( [0x0064, 0x006E, 0x00C8, 0x00CD, 0x2724] ) + cur.device_id = self.o20.device_id + cur.temperature = values[0] + cur.dewpoint = values[1] + cur.relative_humidity = values[2] + cur.absolute_humidity = values[3] + cur.battery_voltage = values[4] + cur.ts = datetime.now().replace(microsecond=0) + self._cached_current_values = cur + return cur + + def _serve_static(self, filename): + return static_file(filename, root=os.path.join(PATH, 'static')) + + def _connected_device(self): + return self.o20.device_id + + def _list_devices(self): + return dict(devices=self.ps.get_device_ids()) + + def _download_device_data(self, device_id): + if clock() - self._download_device_data_last_call < 10.0: + return {'success': True, 'cached': True} + self._download_device_data_last_call = clock() + try: + max_ts = self.ps.max_ts()[device_id] + except KeyError: + max_ts = None + log_data = self.o20.download_logs(start_datetime=max_ts) + self.o20.disconnect() + self.ps.add_data(self.o20.device_id, log_data) + self.ps.persist() + return {'success': True} + + def _plot_history(self, device_id, fileformat): + from io import BytesIO + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import numpy as np + import pandas as pd + + df = pd.DataFrame(self.ps.get_data(device_id)) + + df = df.set_index('ts', drop=True) + df.columns = [OPUS20_CHANNEL_SPEC[col]['name'] for col in df.columns] + + # Handling of URL query variables + color = request.query.get('color', 'b,m,y,r,g,k').split(',') + ylabel = request.query.get('ylabel', 'temperature [°C]') + y2label = request.query.get('y2label', 'humidity [%]') + q_range = request.query.range + if q_range: + if ',' in q_range: + q_range = q_range.split(',') + df = df[q_range[0]:q_range[1]] + else: + df = df[q_range] + else: + q_range = 'All Time' + figsize = request.query.figsize or '10,6' + figsize = (float(num) for num in figsize.split(',')) + dpi = request.query.dpi or self.DPI + dpi = float(dpi) + #resample = request.query.resample or '2min' + measures = request.query.measures + if not measures: + measures = ('temperature', 'relative humidity') + else: + measures = measures.split(',') + right = request.query.get('right', None) + if right is None: + right = ('relative humidity',) + else: + right = right.split(',') + #selected_cols = df.columns + selected_cols = set() + for measure in measures: + for col in df.columns: + if measure in col: + selected_cols.add(col) + selected_cols = list(selected_cols) + right_cols = set() + for col in selected_cols: + for measure in right: + if measure in col: + right_cols.add(col) + right_cols = list(right_cols) + # / End handling URL query variables + + fig, ax = plt.subplots(figsize=figsize) + if len(selected_cols) == 1: color = color[0] + df.ix[:,selected_cols].plot(ax=ax, color=color, grid=True, secondary_y=right_cols, x_compat=True) + ax.set_xlabel('') + ax.set_ylabel(ylabel) + if len(right_cols): plt.ylabel(y2label) + ax.set_title("OPUS20 device: " + device_id) + #start, end = ax.get_xlim() + #ax.xaxis.set_ticks(np.arange(start, end, 1.0)) + #ax.xaxis.grid(True, which="minor") + #ax.legend() + + io = BytesIO() + plt.savefig(io, format=fileformat, dpi=dpi) + plt.close() + response.content_type = self.MIME_MAP[fileformat] + return io.getvalue() + + def disconnect_opus20(self): + self.o20.close() + diff --git a/opus20/opus20/webapp/static/css/style.css b/opus20/opus20/webapp/static/css/style.css new file mode 100644 index 0000000..de1218d --- /dev/null +++ b/opus20/opus20/webapp/static/css/style.css @@ -0,0 +1,15 @@ +/* + * Base structure + */ + +html, +body { + padding-top: 70px; + background-color: #333; + text-align: center; + text-shadow: 0 1px 3px rgba(0,0,0,.5); +} + +pre { + text-align: left; +} diff --git a/opus20/opus20/webapp/static/js/script.js b/opus20/opus20/webapp/static/js/script.js new file mode 100644 index 0000000..dad1e8a --- /dev/null +++ b/opus20/opus20/webapp/static/js/script.js @@ -0,0 +1,21 @@ + + +$(function(){ + $('#fetchData').on('click', function(){ + var $btn = $(this).button('loading') + $.ajax({ + url: "/download/" + device_id, + type: 'get', + success: function (response) { + //console.log('response received'); + location.reload() + $btn.button('reset') + }, error: function (response) { + console.log('ajax request to fetch data failed'); + alert('ajax request to fetch data failed'); + $btn.button('reset') + }, + }); + }); +}); + diff --git a/opus20/opus20/webapp/views/about.jinja2 b/opus20/opus20/webapp/views/about.jinja2 new file mode 100644 index 0000000..28f480e --- /dev/null +++ b/opus20/opus20/webapp/views/about.jinja2 @@ -0,0 +1,17 @@ +{% extends "base.jinja2" %} + +{% block title %}About{% endblock %} + +{% block content %} +

About opus20

+

+ Environmental Monitoring with OPUS20 +

+

+ The Python package opus20 allows for easy environmental monitoring using the OPUS20 logger by Lufft. + It was written by Philipp Klaus for use in the clean room laboratory of the X-Matter Group at the University of Frankfurt. +

+ +{% endblock %} diff --git a/opus20/opus20/webapp/views/base.jinja2 b/opus20/opus20/webapp/views/base.jinja2 new file mode 100644 index 0000000..b75fade --- /dev/null +++ b/opus20/opus20/webapp/views/base.jinja2 @@ -0,0 +1,75 @@ + + + + + + + + + + + + {% block title %}{% endblock %} - opus20 + + + + + + + + + + + + + + + + + + +
+ + +
+ + {% block content %}{% endblock %} + +
+ +
+ + + + + + + + + diff --git a/opus20/opus20/webapp/views/debug.jinja2 b/opus20/opus20/webapp/views/debug.jinja2 new file mode 100644 index 0000000..107ae30 --- /dev/null +++ b/opus20/opus20/webapp/views/debug.jinja2 @@ -0,0 +1,10 @@ +{% extends "base.jinja2" %} + +{% block title %}Debug{% endblock %} + +{% block content %} +

Debugging Information

+

+

{{ debug_dict | pprint }}
+

+{% endblock %} diff --git a/opus20/opus20/webapp/views/plots.jinja2 b/opus20/opus20/webapp/views/plots.jinja2 new file mode 100644 index 0000000..c77bb1c --- /dev/null +++ b/opus20/opus20/webapp/views/plots.jinja2 @@ -0,0 +1,21 @@ +{% extends "base.jinja2" %} + +{% block title %}Plot{% endblock %} + +{% block globals %} +var device_id = "{{ device_id }}"; +{% endblock %} + +{% block content %} +

Plots of the Environment Values


+

+ + +

+

+ +

+

+ +

+{% endblock %} diff --git a/opus20/opus20/webapp/views/status.jinja2 b/opus20/opus20/webapp/views/status.jinja2 new file mode 100644 index 0000000..a892402 --- /dev/null +++ b/opus20/opus20/webapp/views/status.jinja2 @@ -0,0 +1,20 @@ +{% extends "base.jinja2" %} + +{% block title %}Status{% endblock %} + +{% block content %} +

Current Status

+

+

Device ID: {{ current_values.device_id }}
+
Temperature: {{ current_values.temperature | round(1) }} °C
+
Dewpoint: {{ current_values.dewpoint | round(1) }} °C
+
Relative Humidity: {{ current_values.relative_humidity | round(1) }} %
+
Absolute Humidity: {{ current_values.absolute_humidity | round(1) }} g/m³
+
Battery Voltage: {{ current_values.battery_voltage | round(1) }} V
+
+
Date & Time of this information: {{ current_values.ts.isoformat().replace('T', ' - ') }}
+

+ +{% endblock %} diff --git a/opus20/scripts/opus20_cli b/opus20/scripts/opus20_cli new file mode 100755 index 0000000..3453eab --- /dev/null +++ b/opus20/scripts/opus20_cli @@ -0,0 +1,80 @@ +#!/usr/bin/env python + +import pdb +import sys +import time +import argparse +import logging + +from opus20 import Opus20, OPUS20_CHANNEL_SPEC, PickleStore, Opus20ConnectionException + +clock = time.perf_counter +logger = logging.getLogger('opus_cli') + +def extended_int(string): + if string.startswith('0x'): + return int(string, 16) + else: + return int(string) + +def main(): + + parser = argparse.ArgumentParser(description="CLI for the Lufft Opus20") + parser.add_argument('host', help='hostname of the device') + parser.add_argument('--port', '-p', type=int, help='port for TCP connections') + parser.add_argument('--timeout', '-t', type=float, help='timeout for the TCP connection') + parser.add_argument('--loglevel', '-l', choices=['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'], help='log level') + subparsers = parser.add_subparsers(help='cmd help', dest='cmd') + parser_a = subparsers.add_parser('list', help='list all possible channels') + parser_b = subparsers.add_parser('get', help='get the value(s) of specific channel(s)') + parser_b.add_argument('channel', type=extended_int, nargs='+', help='The selected channel(s)') + parser_c = subparsers.add_parser('download', help='download the logs') + parser_c.add_argument('persistance_file', help='file to store the logs in') + args = parser.parse_args() + + if not args.cmd: parser.error('please select a command') + + if args.loglevel: + logging.basicConfig(level=getattr(logging, args.loglevel.upper())) + + start = clock() + o20 = None + try: + kwargs = {} + if args.port: kwargs['port'] = args.port + if args.timeout: kwargs['timeout'] = args.timeout + o20 = Opus20(args.host, **kwargs) + + if args.cmd == 'list': + for channel in o20.available_channels: + print("Channel {:5d} (0x{:04X}): {name:22s} unit: {unit:6s} offset: {offset}".format(channel, channel, **OPUS20_CHANNEL_SPEC[channel])) + if args.cmd == 'get': + if len(args.channel) > 1: + for channel in o20.multi_channel_value(args.channel): + print("{:.3f}".format(channel)) + else: + print("{:.3f}".format(o20.channel_value(args.channel[0]))) + if args.cmd == 'download': + ps = PickleStore(args.persistance_file) + try: + max_ts = ps.max_ts()[o20.device_id] + except KeyError: + max_ts = None + log_data = o20.download_logs(start_datetime=max_ts) + ps.add_data(o20.device_id, log_data) + ps.persist() + + except Opus20ConnectionException as e: + parser.error(str(e)) + + finally: + try: + o20.disconnect() + o20 = None + except: + pass + end = clock() + logger.info("script running time (net): {:.6f} seconds.".format(end-start)) + + +if __name__ == "__main__": main() diff --git a/opus20/scripts/opus20_web b/opus20/scripts/opus20_web new file mode 100755 index 0000000..2f7538e --- /dev/null +++ b/opus20/scripts/opus20_web @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +# local deps +from opus20 import PlotWebServer + +# std lib +import argparse +import logging + +logger = logging.getLogger('opus_web') + + +def main(): + + parser = argparse.ArgumentParser(description="Web interface for the Lufft OPUS20") + parser.add_argument('host', help='hostname of the device') + parser.add_argument('--port', '-p', type=int, help='port of the device for TCP connections') + parser.add_argument('--timeout', '-t', type=float, help='timeout for the TCP connection') + parser.add_argument('--loglevel', '-l', choices=['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'], help='log level') + parser.add_argument('--debug', '-d', action='store_true', help='enable debugging') + args = parser.parse_args() + + if args.loglevel: + logging.basicConfig(level=getattr(logging, args.loglevel.upper())) + + plot_server = None + try: + kwargs = {} + if args.port: kwargs['port'] = args.port + if args.timeout: kwargs['timeout'] = args.timeout + kwargs['debug'] = args.debug + plot_server = PlotWebServer(args.host, '/tmp/opus20-plot-server.pickle', **kwargs) + plot_server.run(host='0.0.0.0', port=45067, debug=args.debug) + + except ConnectionRefusedError as e: + parser.error("Could not connect to host {}: {}".format(args.host, e)) + + finally: + try: + plot_server.disconnect_opus20() + except: + pass + + +if __name__ == "__main__": main() diff --git a/opus20/setup.py b/opus20/setup.py new file mode 100644 index 0000000..069246b --- /dev/null +++ b/opus20/setup.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2015, Philipp Klaus. All rights reserved. + +License: GPLv3 +""" + +from setuptools import setup + +setup(name='opus20', + version = '0.9.6', + description = 'Interface to Lufft OPUS20 devices', + long_description = '', + author = 'Philipp Klaus', + author_email = 'klaus@physik.uni-frankfurt.de', + url = '', + license = 'GPL', + packages = ['opus20', 'opus20.webapp'], + scripts = ['scripts/opus20_cli', 'scripts/opus20_web'], + include_package_data = True, + zip_safe = True, + platforms = 'any', + keywords = 'Lufft Opus20', + classifiers = [ + 'Development Status :: 4 - Beta', + 'Operating System :: OS Independent', + 'License :: OSI Approved :: GPL License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Logging', + ] +) + +