summaryrefslogblamecommitdiffstats
path: root/glucometerutils/drivers/otultra2.py
blob: 9bb52d106c5fa1312d3dcbbc59f33bcdadd052f2 (plain) (tree)
1
2
3
4
5
6
7
                       




                                                        
                   







                                      
                                                   
 

                                                                           


                          

























                                                                   


















                                                                              

















                                                                   
                                              


                                             

                                                           
                    
                                                          























                                                                                   

                                                  



                                                                         







                                                                              
                    
          



                       
                               



                                                           
       
                                                     
                                 

                        
                                        







                                                                               
                           

                                                  
                                             
 
















                                                                               
                        





                                                                       
                                                 

                          
                                                




                                                       
                              










                                                                         
                                                 









                                                                               
                                                              


                        
                         




                                                                 
                                                 
                                        
 
                                                       








                                                                           
                                           

                                                 
                                        
 









                                                                               
                                                           
 

                                                                       
 


                                                             


                                                                  
 


                                                                                
 
       
                                                   

                                                 
                         
 





                                                 
 
                         












                                                                                
                             


                                        
                                         


                                              



                               
                                                               
 
                                       


                                              

                                   


                                                    
 



                                                                                
# -*- coding: utf-8 -*-
"""Driver for LifeScan OneTouch Ultra 2 devices"""

__author__ = 'Diego Elio Pettenò'
__email__ = 'flameeyes@flameeyes.eu'
__copyright__ = 'Copyright © 2013, Diego Elio Pettenò'
__license__ = 'MIT'

import datetime
import re

import serial

from glucometerutils import common
from glucometerutils import exceptions
from glucometerutils.drivers import lifescan_common

# The following two hashes are taken directly from LifeScan's documentation
_MEAL_CODES = {
  'N': common.NO_MEAL,
  'B': common.BEFORE_MEAL,
  'A': common.AFTER_MEAL,
}

_COMMENT_CODES = {
  '00': '',  # would be 'No Comment'
  '01': 'Not Enough Food',
  '02': 'Too Much Food',
  '03': 'Mild Exercise',
  '04': 'Hard Exercise',
  '05': 'Medication',
  '06': 'Stress',
  '07': 'Illness',
  '08': 'Feel Hypo',
  '09': 'Menses',
  '10': 'Vacation',
  '11': 'Other',
}

_DUMP_HEADER_RE = re.compile(
  r'P ([0-9]{3}),"[0-9A-Z]{9}","(?:MG/DL |MMOL/L)"')
_DUMP_LINE_RE = re.compile(
  r'P (?P<datetime>"[A-Z]{3}","[0-9/]{8}","[0-9:]{8}   "),'
  r'"(?P<control>[C ]) (?P<value>[0-9]{3})(?P<parityerror>[\? ])",'
  r'"(?P<meal>[NBA])","(?P<comment>0[0-9]|1[01])", 00')

_RESPONSE_MATCH = re.compile(r'^(.+) ([0-9A-F]{4})\r$')

def _calculate_checksum(bytestring):
  """Calculate the checksum used by OneTouch Ultra and Ultra2 devices

  Args:
    bytestring: the string of which the checksum has to be calculated.

  Returns:
    A string with the hexdecimal representation of the checksum for the input.

  The checksum is a very stupid one: it just sums all the bytes,
  modulo 16-bit, without any parity.
  """
  checksum = 0

  for byte in bytestring:
    checksum = (checksum + byte) & 0xffff

  return checksum

def _validate_and_strip_checksum(line):
  """Verify the simple 16-bit checksum and remove it from the line.

  Args:
    line: the line to check the checksum of.

  Returns:
    A copy of the line with the checksum stripped out.
  """
  match = _RESPONSE_MATCH.match(line)

  if not match:
    raise lifescan_common.MissingChecksum(line)

  response, checksum_string = match.groups()

  try:
    checksum_given = int(checksum_string, 16)
    checksum_calculated = _calculate_checksum(
      bytes(response, 'ascii'))

    if checksum_given != checksum_calculated:
      raise exceptions.InvalidChecksum(checksum_given,
                                       checksum_calculated)
  except ValueError:
    raise exceptions.InvalidChecksum(checksum_given, None)

  return response

_DATETIME_RE = re.compile(
  r'^"[A-Z]{3}","([0-9]{2}/[0-9]{2}/[0-9]{2})","([0-9]{2}:[0-9]{2}:[0-9]{2})   "$')


def _parse_datetime(response):
  """Convert a response with date and time from the meter into a datetime.

  Args:
    response: the response coming from a DMF or DMT command

  Returns:
    A datetime object built according to the returned response.

  Raises:
    InvalidResponse if the string cannot be matched by _DATETIME_RE.
  """
  match = _DATETIME_RE.match(response)
  if not match:
    raise exceptions.InvalidResponse(response)

  date, time = match.groups()
  month, day, year = map(date.split('/'), int)
  hour, minute, second = map(time.split(':'), int)

  # Yes, OneTouch2's firmware is not Y2K safe.
  return datetime.datetime(2000 + year, month, day, hour, minute, second)


class Device(object):
  def __init__(self, device):
    self.serial_ = serial.Serial(
      port=device, baudrate=9600, bytesize=serial.EIGHTBITS,
      parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE,
      timeout=1, xonxoff=False, rtscts=False, dsrdtr=False, writeTimeout=None)

  def connect(self):
    return

  def disconnect(self):
    return

  def _send_command(self, cmd):
    """Send command interface.

    Args:
      cmd: command and parameters to send (without newline)
    """
    cmdstring = bytes('\x11\r' + cmd + '\r', 'ascii')
    self.serial_.write(cmdstring)
    self.serial_.flush()

  def _send_oneliner_command(self, cmd):
    """Send command and read a one-line response.

    Args:
      cmd: command and parameters to send (without newline)

    Returns:
      A single line of text that the glucometer responds, without the checksum.
    """
    self._send_command(cmd)

    line = self.serial_.readline().decode('ascii')
    return _validate_and_strip_checksum(line)

  def get_information_string(self):
    """Returns a single string with all the identification information.

    Returns:
      A string including the serial number, software version, date and time and
      default unit.
    """
    return ('OneTouch Ultra 2 glucometer\n'
            'Serial number: %s\n'
            'Software version: %s\n'
            'Time: %s\n'
            'Default unit: %s' % (
              self.get_serial_number(),
              self.get_version(),
              self.get_datetime(),
              self.get_glucose_unit()))

  def get_version(self):
    """Returns an identifier of the firmware version of the glucometer.

    Returns:
      The software version returned by the glucometer, such as
        "P02.00.00 30/08/06".
    """
    response = self._send_oneliner_command('DM?')

    if response[0] != '?':
      raise exceptions.InvalidResponse(response)

    return response[1:]

  _SERIAL_NUMBER_RE = re.compile('^@ "([A-Z0-9]{9})"$')

  def get_serial_number(self):
    """Retrieve the serial number of the device.

    Returns:
      A string representing the serial number of the device.

    Raises:
      exceptions.InvalidResponse: if the DM@ command returns a string not
        matching _SERIAL_NUMBER_RE.
      InvalidSerialNumber: if the returned serial number does not match
        the OneTouch2 device as per specs.
    """
    response = self._send_oneliner_command('DM@')

    match = self._SERIAL_NUMBER_RE.match(response)
    if not match:
      raise exceptions.InvalidResponse(response)

    serial_number = match.group(1)

    # 'Y' at the far right of the serial number is the indication of a OneTouch
    # Ultra2 device, as per specs.
    if serial_number[-1] != 'Y':
      raise lifescan_common.InvalidSerialNumber(serial_number)

    return serial_number

  def get_datetime(self):
    """Returns the current date and time for the glucometer.

    Returns:
      A datetime object built according to the returned response.
    """
    response = self._send_oneliner_command('DMF')
    return _parse_datetime(response[2:])

  def set_datetime(self, date=datetime.datetime.now()):
    """Sets the date and time of the glucometer.

    Args:
      date: The value to set the date/time of the glucometer to. If none is
        given, the current date and time of the computer is used.

    Returns:
      A datetime object built according to the returned response.
    """
    response = self._send_oneliner_command(
      'DMT' + date.strftime('%m/%d/%y %H:%M:%S'))

    return _parse_datetime(response[2:])

  def zero_log(self):
    """Zeros out the data log of the device.

    This function will clear the memory of the device deleting all the readings
    in an irrecoverable way.
    """
    response = self._send_oneliner_command('DMZ')
    if response != 'Z':
      raise exceptions.InvalidResponse(response)

  _GLUCOSE_UNIT_RE = re.compile(r'^SU\?,"(MG/DL |MMOL/L)"')

  def get_glucose_unit(self):
    """Returns a constant representing the unit displayed by the meter.

    Returns:
      common.UNIT_MGDL: if the glucometer displays in mg/dL
      common.UNIT_MMOLL: if the glucometer displays in mmol/L

    Raises:
      exceptions.InvalidGlucoseUnit: if the unit is not recognized

    OneTouch meters will always dump data in mg/dL because that's their internal
    storage. They will then provide a separate method to read the unit used for
    display. This is not settable by the user in all modern meters.

    """
    response = self._send_oneliner_command('DMSU?')

    match = self._GLUCOSE_UNIT_RE.match(response)
    unit = match.group(1)

    if unit == 'MG/DL ':
      return common.UNIT_MGDL
    elif unit == 'MMOL/L':
      return common.UNIT_MMOLL
    else:
      raise exceptions.InvalidGlucoseUnit(string)

  def get_readings(self):
    """Iterates over the reading values stored in the glucometer.

    Args:
      unit: The glucose unit to use for the output.

    Yields:
      A tuple (date, value) of the readings in the glucometer. The value is a
      floating point in the unit specified; if no unit is specified, the default
      unit in the glucometer will be used.

    Raises:
      exceptions.InvalidResponse: if the response does not match what expected.
    """
    self._send_command('DMP')
    data = self.serial_.readlines()

    header = data.pop(0).decode('ascii')
    match = _DUMP_HEADER_RE.match(header)
    if not match:
      raise exceptions.InvalidResponse(header)

    count = int(match.group(1))
    assert count == len(data)

    for line in data:
      line = _validate_and_strip_checksum(line.decode('ascii'))

      match = _DUMP_LINE_RE.match(line)
      if not match:
        raise exceptions.InvalidResponse(line)

      line_data = match.groupdict()

      date = _parse_datetime(line_data['datetime'])
      meal = _MEAL_CODES[line_data['meal']]
      comment = _COMMENT_CODES[line_data['comment']]

      # OneTouch2 always returns the data in mg/dL even if the glucometer is set
      # to mmol/L, so there is no conversion required.
      yield common.Reading(
        date, float(line_data['value']), meal=meal, comment=comment)