# -*- 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<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: 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
)