def _send_text_command(self, command): """Send a command to the device that expects a text reply.""" self._send_command(self.TEXT_CMD, command) # Reply can stretch multiple buffers full_content = '' while True: message_type, content = self._read_response() if message_type != self.TEXT_REPLY_CMD: raise exceptions.InvalidResponse( 'Message type %02x does not match expectations: %s' % (message_type, content.decode('ascii'))) full_content += content.decode('ascii') if _TEXT_COMPLETION_RE.search(full_content): break match = _TEXT_REPLY_FORMAT.search(full_content) if not match: raise exceptions.InvalidResponse(full_content) message = match.group('message') _verify_checksum(message, match.group('checksum')) if match.group('status') != 'OK': raise exceptions.InvalidResponse(message or "Command failed") return message
def _parse_result(data): """\ I assume empty meter has final line "Log Empty END" and no trailing empty line: blank, dev, soft, date, "Log Empty END" I assume non-empty meter has lines: blank, dev, soft, date, count, blank line, result*count, checksum """ info = _parse_info(data) n = info['device_nrresults_'] if n == 0: len_data1 = _INFO_SIZE else: len_data1 = _INFO_SIZE + n + 2 if data[_INFO_SIZE] != '': logging.debug('_parse_result: last line not blank: %r', data) raise exceptions.InvalidResponse('\n'.join(data)) if len(data) != len_data1: msg = '_parse_result: len(data)= %d correct len_data= %d ' % ( len(data), len_data1) logging.debug(msg + '%r', data) raise exceptions.InvalidResponse('\n'.join(data)) j1 = _INFO_SIZE + 1 j2 = _INFO_SIZE + 1 + n reslog = [_parse_resline(data[j], j) for j in range(j1, j2)] if n > 0: checksum = _parse_checksum(data[j2]) return {'info': info, 'reslog': reslog, 'checksum': checksum}
def _read_text_response(self) -> Sequence[bytes]: all_lines: List[bytes] = [] while True: line = self._readline() if not line.endswith(b"\r\n"): raise exceptions.InvalidResponse( f"Corrupted response line: {line!r}") all_lines.append(line) if line == b"]\r\n": break if all_lines[0] != b"[\r\n": raise exceptions.InvalidResponse( f"Unexpected first response line: {all_lines!r}") wire_checksum = int(all_lines[-2][:-2], base=16) calculated_checksum = _crc8_maxim(b"".join(all_lines[:-2])) if wire_checksum != calculated_checksum: raise exceptions.InvalidChecksum(wire_checksum, calculated_checksum) return [line[:-2] for line in all_lines[1:-2]]
def _get_meter_info(self) -> Sequence[str]: self._send_command(_CMD_GET_INFO) get_info_response = list(self._read_text_response()) if len(get_info_response) != 1: raise exceptions.InvalidResponse( f"Multiple lines returned, when one expected: {get_info_response!r}" ) info = get_info_response[0].split(b",") if len(info) != 5: raise exceptions.InvalidResponse( f"Incomplete information response received: {get_info_response!r}" ) return [component.decode("ascii") for component in info]
def query_multirecord(self, command: bytes) -> Iterator[List[str]]: """Queries for, and returns, "multirecords" results. Multirecords are used for querying events, readings, history and similar other data out of a FreeStyle device. These are comma-separated values, variable-length. The validation includes the general HID framing parsing, as well as validation of the record count, and of the embedded records checksum. Args: command: The text command to send to the device for the query. Returns: A CSV reader object that returns a record for each line in the reply buffer. """ message = self.send_text_command(command) logging.debug("Received multirecord message:\n%s", message) if message == "Log Empty\r\n": return iter(()) match = _MULTIRECORDS_FORMAT.search(message) if not match: raise exceptions.InvalidResponse(message) records_str = match.group("message") _verify_checksum(records_str, match.group("checksum")) logging.debug("Received multi-record string: %s", records_str) return csv.reader(records_str.split("\r\n"))
def _get_multirecord(self, command): """Queries for, and returns, "multirecords" results. Multirecords are used for querying events, readings, history and similar other data out of a FreeStyle device. These are comma-separated values, variable-length. The validation includes the general HID framing parsing, as well as validation of the record count, and of the embedded records checksum. Args: command: (bytes) the text command to send to the device for the query. Returns: (csv.reader): a CSV reader object that returns a record for each line in the record file. """ message = self._send_text_command(command) match = _MULTIRECORDS_FORMAT.search(message) if not match: raise exceptions.InvalidResponse(message) records_str = match.group('message') _verify_checksum(records_str, match.group('checksum')) logging.debug('Received multi-record string: %s', records_str) return csv.reader(records_str.split('\r\n'))
def read_packet(self): preamble = self.serial_.read(3) if len(preamble) != 3: raise exceptione.InvalidResponse( response='Expected 3 bytes, received %d' % len(preamble)) if preamble[0:_IDX_LENGTH] != _RECV_PREAMBLE: raise exceptions.InvalidResponse( response='Unexpected preamble %r' % pramble[0:_IDX_LENGTH]) msglen = preamble[_IDX_LENGTH] message = self.serial_.read(msglen) if len(message) != msglen: raise exception.InvalidResponse( response='Expected %d bytes, received %d' % (msglen, len(message))) if message[_IDX_ETX] != _ETX: raise exception.InvalidResponse( response='Unexpected end-of-transmission byte: %02x' % message[_IDX_ETX]) # Calculate the checksum up until before the checksum itself. msgdata = message[:_IDX_CHECKSUM] cksum = xor_checksum(msgdata) if cksum != message[_IDX_CHECKSUM]: raise exception.InvalidChecksum(message[_IDX_CHECKSUM], cksum) return msgdata
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.InvalidSerialNumber(serial_number) return serial_number
def _parse_checksum(line): match = _CHECKSUM_RE.match(line) if not match: raise exceptions.InvalidResponse('\n'.join(line)) checksum_str = match.group('checksum') checksum = int(checksum_str, 16) return checksum
def _set_device_datetime(self, date: datetime.datetime) -> datetime.datetime: data = self._send_command(date.strftime("tim,%m,%d,%y,%H,%M")) parsed_data = "".join(data) if parsed_data != "CMD OK": raise exceptions.InvalidResponse(parsed_data) return self.get_datetime()
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)
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.GlucoseReading(date, float(line_data['value']), meal=meal, comment=comment)
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)
def _extract_meal(self, record): # pylint: disable=no-self-use if record[_AFTER_MEAL_CSV_KEY] and record[_BEFORE_MEAL_CSV_KEY]: raise exceptions.InvalidResponse( 'Reading cannot be before and after meal.') elif record[_AFTER_MEAL_CSV_KEY]: return common.Meal.AFTER elif record[_BEFORE_MEAL_CSV_KEY]: return common.Meal.BEFORE else: return common.Meal.NONE
def _parse_info(data): info = {} if len(data) < _INFO_SIZE: msg = '_parse_info: len(data)=%d < %d lines: ' % (len(data), _INFO_SIZE) logging.debug(msg + '%r', data) raise exceptions.InvalidResponse('\n'.join(data)) if data[0] != '': msg = '_parse_info: first line not blank: ' logging.debug(msg + '%r', data) raise exceptions.InvalidResponse('\n'.join(data)) info['device_version_'] = data[1] info['software_revision_'] = data[2] info['device_serialno_'] = 'N/A' info['device_glucose_unit_'] = common.Unit.MG_DL # info['device_glucose_unit_'] = common.Unit.MMOL_L info['device_current_date_time_'] = _parse_clock(data[3]) info['device_nrresults_'] = _parse_nrresults(data[4]) return info
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:]
def _send_text_command(self, command): # type: (bytes) -> Text """Send a command to the device that expects a text reply.""" self._send_command(self.TEXT_CMD, command) # Reply can stretch multiple buffers full_content = b'' while True: message_type, content = self._read_response() logging.debug('Received message: type %02x content %s', message_type, content.hex()) if message_type != self.TEXT_REPLY_CMD: raise exceptions.InvalidResponse( 'Message type %02x does not match expectations: %r' % (message_type, content)) full_content += content if _TEXT_COMPLETION_RE.search(full_content): break match = _TEXT_REPLY_FORMAT.search(full_content) if not match: raise exceptions.InvalidResponse(full_content) message = match.group('message') _verify_checksum(message, match.group('checksum')) if match.group('status') != b'OK': raise exceptions.InvalidResponse(message or "Command failed") # If there is anything in the response that is not ASCII-safe, this is # probably in the patient name. The Windows utility does not seem to # validate those, so just replace anything non-ASCII with the correct # unknown codepoint. return message.decode('ascii', 'replace')
def send_text_command(self, command: bytes) -> str: """Send a command to the device that expects a text reply.""" self.send_command(self._text_message_type, command) # Reply can stretch multiple buffers full_content = b"" while True: message_type, content = self.read_response() logging.debug( "Received message: type %02x content %s", message_type, content.hex() ) if message_type != self._text_reply_message_type: raise exceptions.InvalidResponse( f"Message type {message_type:02x}: content does not match expectations: {content!r}" ) full_content += content if _TEXT_COMPLETION_RE.search(full_content): break match = _TEXT_REPLY_FORMAT.search(full_content) if not match: raise exceptions.InvalidResponse(repr(full_content)) message = match.group("message") _verify_checksum(message, match.group("checksum")) if match.group("status") != b"OK": raise exceptions.InvalidResponse(repr(message) or "Command failed") # If there is anything in the response that is not ASCII-safe, this is # probably in the patient name. The Windows utility does not seem to # validate those, so just replace anything non-ASCII with the correct # unknown codepoint. return message.decode("ascii", "replace")
def set_datetime(self, date=datetime.datetime.now()): setdatecmd = date.strftime('ADATE%Y%m%d%H%M').encode('ascii') # Ignore the readings count. self.wait_and_ready() self.send_message(setdatecmd) response = self.read_message() if response != _DATE_SET_MESSAGE: raise exceptions.InvalidResponse(response=response) # The date we return should only include up to minute, unfortunately. return datetime.datetime(date.year, date.month, date.day, date.hour, date.minute)
def _parse_nrresults(countstr): """Convert the count string used by the device into number of results. Args: countstr: a string as returned by the device during information handling. Special case: no results ('Log Empty END') returns 0. """ match_empty = _EMPTY_RE.match(countstr) match_count = _COUNT_RE.match(countstr) if match_empty: return 0 elif match_count: return int(countstr) else: raise exceptions.InvalidResponse(countstr)
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. """ data = self._send_command("colq") for line in data: if not line.startswith("Clock:"): continue return _parse_clock(line) raise exceptions.InvalidResponse("\n".join(data))
def _parse_clock(datestr): """Convert the date/time string used by the the device into a datetime. Args: datestr: a string as returned by the device during information handling. """ match = _CLOCK_RE.match(datestr) if not match: raise exceptions.InvalidResponse(datestr) # int() parses numbers in decimal, so we don't have to worry about '08' day = int(match.group('day')) month = _MONTH_MATCHES[match.group('month')] year = int(match.group('year')) time = match.group('time') hour, minute, second = map(int, time.split(':')) return datetime.datetime(year, month, day, hour, minute, second)
def _send_command(self, message_type, command): """Send a raw command to the device. Args: message_type: (int) The first byte sent with the report to the device. command: (bytes) The command to send out the device. """ cmdlen = len(command) assert cmdlen <= 62 # First byte in the written buffer is the report number, on Linux HID # interface. usb_packet = b'\x00' + _STRUCT_PREAMBLE.pack( message_type, cmdlen) + command + bytes(62 - cmdlen) if self.handle_.write(usb_packet) < 0: raise exceptions.InvalidResponse()
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. """ data = self._send_command(date.strftime('tim,%m,%d,%y,%H,%M')) parsed_data = ''.join(data) if parsed_data != 'CMD OK': raise exceptions.InvalidResponse(parsed_data) return self.get_datetime()
def _set_device_datetime(self, date: datetime.datetime) -> datetime.datetime: datetime_representation = date.strftime("%y%m%d%H%M").encode("ascii") command_string = b"[\r\n" + datetime_representation + b"\r\n" checksum = _crc8_maxim(command_string) assert 0 <= checksum <= 255 command_string += f"{checksum:02X}".encode("ascii") + b"\r\n]\r\n" command = _CMD_SET_DATETIME + command_string self._send_command(command) response = self.serial_.read() if response == b"P": return date else: raise exceptions.InvalidResponse( f"Unexpected response {response!r}.")
def _parse_clock(datestr: str) -> datetime.datetime: """Convert the date/time string used by the device into a datetime. Args: datestr: a string as returned by the device during information handling. """ match = _CLOCK_RE.match(datestr) if not match: raise exceptions.InvalidResponse(datestr) # int() parses numbers in decimal, so we don't have to worry about '08' day = int(match.group("day")) month = _MONTH_MATCHES[match.group("month")] year = int(match.group("year")) hour, minute, second = (int(x) for x in match.group("time").split(":")) return datetime.datetime(year, month, day, hour, minute, second)
def _send_command( self, command: int, message: bytes = _EMPTY_MESSAGE, validate_response: bool = True, ) -> Tuple[int, bytes]: pkt = _make_packet(command, message) logging.debug("sending packet: %s", binascii.hexlify(pkt)) self.serial_.write(pkt) self.serial_.flush() response = self.buffered_reader_.parse_stream(self.serial_) logging.debug("received packet: %r", response) if validate_response and response.data.value.command != command: raise exceptions.InvalidResponse(response) return response.data.value.command, response.data.value.message
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(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)
def _parse_resline(line, j): match = _READING_RE.match(line) if not match: raise exceptions.InvalidResponse('error line %d = %s' % (j, line)) READING = match.group('reading') MONTH = match.group('month') DAY = match.group('day') YEAR = match.group('year') TIME = match.group('time') TYPE = match.group('type') SENTINEL = match.group('sentinel') if TYPE != '00': logging.warning('TYPE == %s NORMALLY type == 00' % TYPE) if READING == 'HI ': value = float("inf") else: value = float(READING) month = _MONTH_MATCHES[MONTH] day = int(DAY) year = int(YEAR) hour, minute = map(int, TIME.split(':')) timestamp = datetime.datetime(year, month, day, hour, minute) # The reading, if present, is always in mg/dL even if the glucometer is # set to mmol/L. # fixme return { 'value': value, 'day': day, 'month': month, 'year': year, 'hour': hour, 'minute': minute, 'timestamp': timestamp, 'READING': READING, 'TYPE': TYPE, 'SENTINEL': SENTINEL }
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 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. """ data = self._send_command("xmem") # The first line is empty, the second is the serial number, the third # the version, the fourth the current time, and the fifth the record # count.. The last line has a checksum and the end. count = int(data[4]) if count != (len(data) - 6): raise exceptions.InvalidResponse("\n".join(data)) # Extract the checksum from the last line. checksum_match = _CHECKSUM_RE.match(data[-1]) if not checksum_match: raise exceptions.InvalidResponse("\n".join(data)) expected_checksum = int(checksum_match.group("checksum"), 16) # exclude the last line in the checksum calculation, as that's the # checksum itself. The final \r\n is added separately. calculated_checksum = sum(ord(c) for c in "\r\n".join(data[:-1])) + 0xD + 0xA if expected_checksum != calculated_checksum: raise exceptions.InvalidChecksum(expected_checksum, calculated_checksum) for line in data[5:-1]: match = _READING_RE.match(line) if not match: raise exceptions.InvalidResponse(line) if match.group("type") != "G": logging.warning("Non-glucose readings are not supported, ignoring.") continue if match.group("reading") == "HI ": value = float("inf") else: value = float(match.group("reading")) day = int(match.group("day")) month = _MONTH_MATCHES[match.group("month")] year = int(match.group("year")) hour, minute = map(int, match.group("time").split(":")) timestamp = datetime.datetime(year, month, day, hour, minute) # The reading, if present, is always in mg/dL even if the glucometer # is set to mmol/L. yield common.GlucoseReading(timestamp, value)