Source code for msl.equipment.resources.omega.ithx

"""
OMEGA iTHX Series Temperature and Humidity Chart Recorder.

This class is compatible with the following model numbers:

* iTHX-W3
* iTHX-D3
* iTHX-SD
* iTHX-M
* iTHX-W
* iTHX-2
"""
from __future__ import annotations

import os
import re
import sqlite3
import time
from datetime import datetime

from msl.equipment.connection_socket import ConnectionSocket
from msl.equipment.exceptions import OmegaError
from msl.equipment.resources import register


[docs] @register(manufacturer=r'OMEGA', model=r'iTHX-[2DMSW][3D]?', flags=re.IGNORECASE) class iTHX(ConnectionSocket): def __init__(self, record): """OMEGA iTHX Series Temperature and Humidity Chart Recorder. The :attr:`~msl.equipment.record_types.ConnectionRecord.properties` for an iTHX connection supports the following key-value pairs in the :ref:`connections-database`:: 'nprobes': int, the number of probes the device has 'nbytes': int, the number of bytes to read from each probe as well as those key-value pairs supported by the parent :class:`~msl.equipment.connection_socket.ConnectionSocket` class. Do not instantiate this class directly. Use the :meth:`~.EquipmentRecord.connect` method to connect to the equipment. Parameters ---------- record : :class:`~.EquipmentRecord` A record from an :ref:`equipment-database`. """ super(iTHX, self).__init__(record) self.set_exception_class(OmegaError)
[docs] def temperature(self, probe=1, celsius=True, nbytes=None): """Read the temperature. Parameters ---------- probe : :class:`int`, optional The probe number to read the temperature of (for iTHX's that contain multiple probes). celsius : class:`bool`, optional :data:`True` to return the temperature in celsius, :data:`False` for fahrenheit. nbytes : class:`int`, optional The number of bytes to read. If :data:`None` then read until the termination character sequence. Returns ------- :class:`float` or :class:`tuple` of :class:`float` The temperature. """ msg = 'TC' if celsius else 'TF' return self._get(msg, probe, size=nbytes)
[docs] def humidity(self, probe=1, nbytes=None): """Read the percent humidity. Parameters ---------- probe : :class:`int`, optional The probe number to read the humidity of (for iTHX's that contain multiple probes). nbytes : class:`int`, optional The number of bytes to read. If :data:`None` then read until the termination character sequence. Returns ------- :class:`float` or :class:`tuple` of :class:`float` The percent humidity. """ return self._get('H', probe, size=nbytes)
[docs] def dewpoint(self, probe=1, celsius=True, nbytes=None): """Read the dew point. Parameters ---------- probe : :class:`int`, optional The probe number to read the dew point of (for iTHX's that contain multiple probes). celsius : :class:`bool`, optional :data:`True` to return the dew point in celsius, :data:`False` for fahrenheit. nbytes : class:`int`, optional The number of bytes to read. If :data:`None` then read until the termination character sequence. Returns ------- :class:`float` or :class:`tuple` of :class:`float` The dew point. """ msg = 'DC' if celsius else 'DF' return self._get(msg, probe, size=nbytes)
[docs] def temperature_humidity(self, probe=1, celsius=True, nbytes=None): """Read the temperature and the humidity. Parameters ---------- probe : :class:`int`, optional The probe number to read the temperature and humidity of (for iTHX's that contain multiple probes). celsius : :class:`bool`, optional :data:`True` to return the temperature in celsius, :data:`False` for fahrenheit. nbytes : class:`int`, optional The number of bytes to read. If :data:`None` then read until the termination character sequence. If specified, `nbytes` is the combined value to read both values. Returns ------- :class:`float` The temperature. :class:`float` The humidity. """ # iTHX-D3 and iTHX-W3 support the *SRB and *SRBF commands, # however, the returned bytes are of the form b'019.4\r,057.0\r' # and if nbytes is None then the socket would stop reading bytes # at the first instance of '\r' and leave ',057.0\r' in the buffer. # # Also, iTHX-W and iTHX-2 do not support the *SRB and *SRBF commands. # # With these complications we do not use the *SRB and *SRBF # commands and read the temperature and humidity sequentially. if nbytes is not None: nbytes = nbytes//2 t = self.temperature(probe=probe, celsius=celsius, nbytes=nbytes) h = self.humidity(probe=probe, nbytes=nbytes) return t, h
[docs] def temperature_humidity_dewpoint(self, probe=1, celsius=True, nbytes=None): """Read the temperature, the humidity and the dew point. Parameters ---------- probe : :class:`int`, optional The probe number to read the temperature, humidity and dew point (for iTHX's that contain multiple probes). celsius : :class:`bool`, optional If :data:`True` then return the temperature and dew point in celsius, :data:`False` for fahrenheit. nbytes : :class:`int`, optional The number of bytes to read. If :data:`None` then read until the termination character sequence. If specified, `nbytes` is the combined value to read all three values. Returns ------- :class:`float` The temperature. :class:`float` The humidity. :class:`float` The dew point. """ nth = None if nbytes is None else (nbytes*2)//3 nd = None if nbytes is None else nbytes//3 t, h = self.temperature_humidity(probe=probe, celsius=celsius, nbytes=nth) return t, h, self.dewpoint(probe=probe, celsius=celsius, nbytes=nd)
[docs] def reset(self, wait=True, password=None, port=2002, timeout=10): """Power reset the iServer. Some iServers accept the reset command to be sent via the TCP/UDP protocol and some require the reset command to be sent via the Telnet protocol. Parameters ---------- wait : :class:`bool`, optional Whether to wait for the connection to the iServer to be re-established before returning to the calling program. Rebooting an iServer takes about 10 to 15 seconds. password : :class:`str`, optional The administrator's password of the iServer. If not specified then uses the default manufacturer's password. Only used if the iServer needs to be reset via the Telnet protocol. port : :class:`int`, optional The port to use for the Telnet connection. Only used if the iServer needs to be reset via the Telnet protocol. timeout : :class:`float`, optional The timeout value to use during the Telnet session. Only used if the iServer needs to be reset via the Telnet protocol. """ def use_telnet(): from telnetlib import Telnet pw = password or '00000000' with Telnet(self.host, port, timeout=timeout) as tn: tn.read_until(b'Password:', timeout=timeout) tn.write(pw.encode() + b'\n') tn.read_until(b'Login Successful', timeout=timeout) tn.write(b'reset\n') tn.read_until(b'The unit will reset in 5 seconds.', timeout=timeout) if wait: # 5 seconds from the Telnet message # 10 seconds for the time it takes to reboot time.sleep(15) self.reconnect(max_attempts=-1) # according to the manual, these models require Telnet if self.equipment_record.model in ['iTHX-W', 'iTHX-2']: return use_telnet() # The manual indicates that iTHX-W3, iTHX-D3, iTHX-SD and iTHX-M # all accept the *SRYRST command reply = self.query('*SRYRST').strip() if reply == 'Reset': # this was the reply that was received with an iTHX-W3 # which accepts the reset command via TCP/UDP if wait: time.sleep(10) self.reconnect(max_attempts=-1) elif reply == 'Serial Time Out': # this was the reply that was received with an iTHX-W # which does not recognize the *SRYRST command use_telnet() else: self.raise_exception( 'Received an unexpected reply, {!r}, for the *SRYRST command'.format(reply) )
[docs] def start_logging(self, path, wait=60, nprobes=None, nbytes=None, celsius=True, msg_format=None, db_timeout=10, validator=None): """Start logging the temperature, humidity and dew point to the specified path. The information is logged to an SQLite_ database. To stop logging press ``CTRL+C``. .. _SQLite: https://www.sqlite.org/index.html Parameters ---------- path : :class:`str` The path to the SQLite_ database. If you only specify a directory then a database with the default filename, ``model_serial.sqlite3``, is created/opened in this directory. wait : :class:`int`, optional The number of seconds to wait between each log event. nprobes : :class:`int`, optional The number of probes that the iServer has (1 or 2). If not specified then gets the value defined in :attr:`~msl.equipment.record_types.ConnectionRecord.properties`. Default is 1. nbytes : :class:`int`, optional The number of bytes to read from each probe (the probes are read sequentially). The value is passed to :meth:`.temperature_humidity_dewpoint`. If not specified then gets the value defined in :attr:`~msl.equipment.record_types.ConnectionRecord.properties`. Default is :data:`None`. celsius : :class:`bool`, optional :data:`True` to return the temperature and dew point in celsius, :data:`False` for fahrenheit. msg_format : :class:`str`, optional The format to use for the INFO :mod:`logging` messages each time data is read from an iServer. The format must use the :meth:`str.format` syntax, ``{}``. The positional arguments to :meth:`str.format` are the values from the iServer, where the values are `(temperature, humidity, dewpoint)` for a 1-probe sensor and `(temperature1, humidity1, dewpoint1, temperature2, humidity2, dewpoint2)` for a 2-probe sensor. The keyword arguments to :meth:`str.format` are the attributes of an :class:`~.EquipmentRecord`. Examples: * T={0} H={1} D={2} * {connection[address]} T={0:.1f} H={1:.1f} D={2:.1f} * T1={0} T2={3} H1={1} H2={4} D1={2} D2={5} * {alias} {serial} -> T={0}C H={1}% D={2}C db_timeout : :class:`float`, optional The number of seconds the connection to the database should wait for the lock to go away until raising an exception. validator A callback that is used to validate the data. The callback must accept two arguments `(data, ithx)`, where `data` is a :class:`tuple` of the temperature, humidity and dewpoint values for each probe and `ithx` is the :class:`.iTHX` instance (i.e., `self`). The callback must return a value whose truthness decides whether to insert the data into the database. If the returned value evaluates to :data:`True` then the data is inserted into the database. """ if os.path.isdir(path): filename = self.equipment_record.model + '_' + self.equipment_record.serial + '.sqlite3' path = os.path.join(path, filename) record_as_dict = self.equipment_record.to_dict() db = sqlite3.connect(path, timeout=db_timeout) self.log_info('start logging to %s', path) props = self.equipment_record.connection.properties if nprobes is None: nprobes = props.get('nprobes', 1) if nbytes is None: nbytes = props.get('nbytes', None) if nprobes == 1: db.execute( 'CREATE TABLE IF NOT EXISTS data (' 'pid INTEGER PRIMARY KEY AUTOINCREMENT, ' 'datetime DATETIME, ' 'temperature FLOAT, ' 'humidity FLOAT, ' 'dewpoint FLOAT);' ) if not msg_format: msg_format = 'Sn={serial} T={0} H={1} D={2}' elif nprobes == 2: db.execute( 'CREATE TABLE IF NOT EXISTS data (' 'pid INTEGER PRIMARY KEY AUTOINCREMENT, ' 'datetime DATETIME, ' 'temperature1 FLOAT, ' 'humidity1 FLOAT, ' 'dewpoint1 FLOAT, ' 'temperature2 FLOAT, ' 'humidity2 FLOAT, ' 'dewpoint2 FLOAT);' ) if not msg_format: msg_format = 'Sn={serial} T1={0} H1={1} D1={2} T2={3} H2={4} D2={5}' else: raise ValueError('The number-of-probes value must be either 1 or 2. Got {}'.format(nprobes)) db.commit() db.close() try: while True: t0 = time.time() # get the values try: data = self.temperature_humidity_dewpoint(probe=1, celsius=celsius, nbytes=nbytes) if nprobes == 2: data += self.temperature_humidity_dewpoint(probe=2, celsius=celsius, nbytes=nbytes) self.log_info(msg_format.format(*data, **record_as_dict)) except Exception as e: self.log_error('%s: %s', e.__class__.__name__, e) self.reconnect(max_attempts=-1) continue now = datetime.now().replace(microsecond=0).isoformat(sep='T') if validator is not None and not validator(data, self): time.sleep(max(0.0, wait - (time.time() - t0))) continue # save the values to the database and then wait values = [now] + list(data) try: db = sqlite3.connect(path, timeout=db_timeout) if nprobes == 1: db.execute('INSERT INTO data VALUES (NULL, ?, ?, ?, ?);', values) else: db.execute('INSERT INTO data VALUES (NULL, ?, ?, ?, ?, ?, ?, ?);', values) db.commit() db.close() except sqlite3.DatabaseError as e: db.close() self.log_error('%s: %s', e.__class__.__name__, e) else: time.sleep(max(0.0, wait - (time.time() - t0))) except (KeyboardInterrupt, SystemExit): pass db.close() self.log_info('stopped logging to %s', path)
[docs] @staticmethod def data(path, start=None, end=None, as_datetime=True, select='*'): """Fetch all the log records between two dates. Parameters ---------- path : :class:`str` The path to the SQLite_ database. start : :class:`~datetime.datetime` or :class:`str`, optional Include all records that have a timestamp :math:`\\ge` `start`. If a :class:`str` then in the ISO 8601 ``yyyy-mm-dd`` or ``yyyy-mm-ddTHH:MM:SS`` format. end : :class:`~datetime.datetime` or :class:`str`, optional Include all records that have a timestamp :math:`\\le` `end`. If a :class:`str` then in the ISO 8601 ``yyyy-mm-dd`` or ``yyyy-mm-ddTHH:MM:SS`` format. as_datetime : :class:`bool`, optional Whether to fetch the timestamps in the database as :class:`~datetime.datetime` objects. If :data:`False` then the timestamps will be of type :class:`str` and this function will return much faster if requesting data over a large date range. select : :class:`str` or :class:`list` of :class:`str`, optional The field name(s) in the database table to use for the ``SELECT`` SQL command (e.g., ``'datetime,temperature'`` or ``['datetime', 'humidity']``). Returns ------- :class:`list` of :class:`tuple` A list of ``(pid, datetime, temperature, humidity, dewpoint, ...)`` log records, depending on the value of `select`. """ if not os.path.isfile(path): raise OSError('Cannot find {}'.format(path)) detect_types = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES if as_datetime else 0 db = sqlite3.connect(path, timeout=10.0, detect_types=detect_types) cursor = db.cursor() if select != '*': if isinstance(select, (list, tuple, set)): select = ','.join(select) base = 'SELECT {} FROM data'.format(select) if isinstance(start, datetime): start = start.isoformat(sep='T') if isinstance(end, datetime): end = end.isoformat(sep='T') if start is None and end is None: cursor.execute(base + ';') elif start is not None and end is None: cursor.execute(base + ' WHERE datetime >= ?;', (start,)) elif start is None and end is not None: cursor.execute(base + ' WHERE datetime <= ?;', (end,)) else: cursor.execute(base + ' WHERE datetime BETWEEN ? AND ?;', (start, end)) data = cursor.fetchall() cursor.close() db.close() return data
def _get(self, message, probe, size=None): if not (probe == 1 or probe == 2): # iTHX-SD supports probe=3 but we don't have one of those devices to test self.raise_exception('Invalid probe number, {}. Must be either 1 or 2'.format(probe)) command = '*SR' + message if probe > 1: command += str(probe) try: ret = self.query(command, size=size) except ConnectionResetError: # for some reason the socket closes if a certain amount of time passes and no # messages have been sent. For example, querying the temperature, humidity and # dew point every >60 seconds raised: # [Errno errno.ECONNRESET] An existing connection was forcibly closed by the remote host self.reconnect(max_attempts=1) return self._get(message, probe, size=size) # retry else: values = tuple(float(v) for v in re.split(r'[,;]', ret)) if len(values) == 1: return values[0] else: return values
[docs] def convert_datetime(value): """Convert a date and time to a :class:`~datetime.datetime` object. Parameters ---------- value : :class:`bytes` The datetime value from an SQLite database. Returns ------- :class:`datetime.datetime` The `value` as a datetime object. """ try: # datetime.fromisoformat is available in Python 3.7+ return datetime.fromisoformat(value.decode()) except AttributeError: # mimics the sqlite3.dbapi2.convert_timestamp function datepart, timepart = value[:10], value[11:] year, month, day = map(int, datepart.split(b'-')) timepart_full = timepart.split(b'.') hours, minutes, seconds = map(int, timepart_full[0].split(b':')) if len(timepart_full) == 2: microseconds = int('{:0<6.6}'.format(timepart_full[1].decode())) else: microseconds = 0 return datetime(year, month, day, hours, minutes, seconds, microseconds)
# Do not use the builtin TIMESTAMP converter since it does not support # the T separator between the date and time. Also, according to # https://www.sqlite.org/lang_datefunc.html the name DATETIME seems # to be more logical than TIMESTAMP as a field name. sqlite3.register_converter('DATETIME', convert_datetime)