# -*- coding: utf-8 -*- # # SPDX-FileCopyrightText: © 2013 The glucometerutils Authors # SPDX-License-Identifier: MIT """Driver for LifeScan OneTouch Ultra 2 devices. Supported features: - get readings, including pre-/post-meal notes and other comments; - use the glucose unit preset on the device by default; - get and set date and time; - get serial number and software version; - memory reset (caution!) Expected device path: /dev/ttyUSB0 or similar serial port device. """ import datetime import re from collections.abc import Generator from glucometerutils import common, driver, exceptions from glucometerutils.support import lifescan, serial # The following two hashes are taken directly from LifeScan's documentation _MEAL_CODES = { "N": common.Meal.NONE, "B": common.Meal.BEFORE, "A": common.Meal.AFTER, } _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"[A-Z]{3}","[0-9/]{8}","[0-9:]{8} "),' r'"(?P[C ]) (?P[0-9]{3})(?P[\? ])",' r'"(?P[NBA])","(?P0[0-9]|1[01])", 00' ) _RESPONSE_MATCH = re.compile(r"^(.+) ([0-9A-F]{4})\r$") def _calculate_checksum(bytestring: bytes) -> int: """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: str) -> str: """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.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}",' r'"([0-9]{2}/[0-9]{2}/[0-9]{2})","([0-9]{2}:[0-9]{2}:[0-9]{2}) "$' ) def _parse_datetime(response: str) -> datetime.datetime: """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(int, date.split("/")) hour, minute, second = map(int, time.split(":")) # Yes, OneTouch2's firmware is not Y2K safe. return datetime.datetime(2000 + year, month, day, hour, minute, second) class Device(serial.SerialDevice, driver.GlucometerDevice): BAUDRATE = 9600 DEFAULT_CABLE_ID = "067b:2303" # Generic PL2303 cable. def connect(self) -> None: # pylint: disable=no-self-use return def disconnect(self) -> None: # pylint: disable=no-self-use return def _send_command(self, cmd: str) -> None: """Send command interface. Args: cmd: command and parameters to send (without newline) """ cmdstring = bytes(f"\x11\r{cmd}\r", "ascii") self.serial_.write(cmdstring) self.serial_.flush() def _send_oneliner_command(self, cmd: str) -> str: """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_meter_info(self) -> common.MeterInfo: """Fetch and parses the device information. Returns: A common.MeterInfo object. """ return common.MeterInfo( "OneTouch Ultra 2 glucometer", serial_number=self.get_serial_number(), version_info=("Software version: " + self.get_version(),), native_unit=self.get_glucose_unit(), ) def get_version(self) -> str: """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) -> str: """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.InvalidSerialNumber(serial_number) return serial_number def get_datetime(self) -> datetime.datetime: """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_device_datetime(self, date: datetime.datetime) -> datetime.datetime: response = self._send_oneliner_command( "DMT" + date.strftime("%m/%d/%y %H:%M:%S") ) return _parse_datetime(response[2:]) def zero_log(self) -> None: """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) -> common.Unit: """Returns a constant representing the unit displayed by the meter. Returns: common.Unit.MG_DL: if the glucometer displays in mg/dL common.Unit.MMOL_L: 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) if match is None: raise exceptions.InvalidGlucoseUnit(response) unit = match.group(1) if unit == "MG/DL ": return common.Unit.MG_DL if unit == "MMOL/L": return common.Unit.MMOL_L raise exceptions.InvalidGlucoseUnit(response) def get_readings(self) -> Generator[common.AnyReading, None, None]: """Iterates over the reading values stored in the glucometer. Args: unit: The glucose unit to use for the output. Yields: A GlucoseReading object representing the read value. 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.GlucoseReading( date, float(line_data["value"]), meal=meal, comment=comment )