From 821414fb74a69a2f041124839232cb721fb214a9 Mon Sep 17 00:00:00 2001
From: Philipp Klaus
Date: Tue, 30 Jun 2015 15:06:40 +0200
Subject: [PATCH] adding the opus20 package
---
opus20/MANIFEST.in | 2 +
opus20/README.md | 89 +++
opus20/gitignore | 10 +
opus20/opus20/__init__.py | 8 +
opus20/opus20/opus20.py | 700 ++++++++++++++++++++++
opus20/opus20/webapp/__init__.py | 207 +++++++
opus20/opus20/webapp/static/css/style.css | 15 +
opus20/opus20/webapp/static/js/script.js | 21 +
opus20/opus20/webapp/views/about.jinja2 | 17 +
opus20/opus20/webapp/views/base.jinja2 | 75 +++
opus20/opus20/webapp/views/debug.jinja2 | 10 +
opus20/opus20/webapp/views/plots.jinja2 | 21 +
opus20/opus20/webapp/views/status.jinja2 | 20 +
opus20/scripts/opus20_cli | 80 +++
opus20/scripts/opus20_web | 45 ++
opus20/setup.py | 37 ++
16 files changed, 1357 insertions(+)
create mode 100644 opus20/MANIFEST.in
create mode 100644 opus20/README.md
create mode 100644 opus20/gitignore
create mode 100644 opus20/opus20/__init__.py
create mode 100644 opus20/opus20/opus20.py
create mode 100755 opus20/opus20/webapp/__init__.py
create mode 100644 opus20/opus20/webapp/static/css/style.css
create mode 100644 opus20/opus20/webapp/static/js/script.js
create mode 100644 opus20/opus20/webapp/views/about.jinja2
create mode 100644 opus20/opus20/webapp/views/base.jinja2
create mode 100644 opus20/opus20/webapp/views/debug.jinja2
create mode 100644 opus20/opus20/webapp/views/plots.jinja2
create mode 100644 opus20/opus20/webapp/views/status.jinja2
create mode 100755 opus20/scripts/opus20_cli
create mode 100755 opus20/scripts/opus20_web
create mode 100644 opus20/setup.py
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.
+