# encoding: utf-8 # # Handles devices reporting themselves as USB VID/PID 0C45:7401 (mine also says # RDing TEMPerV1.2). # # Copyright 2012-2020 Philipp Adelt and contributors. # # This code is licensed under the GNU public license (GPL). See LICENSE.md for # details. import usb import os import re import logging import struct from .device_library import DEVICE_LIBRARY, TemperType, TemperConfig VIDPIDS = [ (0x0c45, 0x7401), (0x0c45, 0x7402), (0x1a86, 0xe025), ] REQ_INT_LEN = 8 ENDPOINT = 0x82 INTERFACE = 1 CONFIG_NO = 1 TIMEOUT = 5000 USB_PORTS_STR = r'^\s*(\d+)-(\d+(?:\.\d+)*)' CALIB_LINE_STR = USB_PORTS_STR +\ r'\s*:\s*scale\s*=\s*([+|-]?\d*\.\d+)\s*,\s*offset\s*=\s*([+|-]?\d*\.\d+)' USB_SYS_PREFIX = '/sys/bus/usb/devices/' COMMANDS = { 'temp': b'\x01\x80\x33\x01\x00\x00\x00\x00', 'ini1': b'\x01\x82\x77\x01\x00\x00\x00\x00', 'ini2': b'\x01\x86\xff\x01\x00\x00\x00\x00', } LOGGER = logging.getLogger(__name__) CONTRIBUTE_URL = "https://github.com/padelt/temper-python/issues" def readattr(path, name): """ Read attribute from sysfs and return as string """ try: f = open(USB_SYS_PREFIX + path + "/" + name) return f.readline().rstrip("\n") except IOError: return None def find_ports(device): """ Find the port chain a device is plugged on. This is done by searching sysfs for a device that matches the device bus/address combination. Useful when the underlying usb lib does not return device.port_number for whatever reason. """ bus_id = device.bus dev_id = device.address for dirent in os.listdir(USB_SYS_PREFIX): matches = re.match(USB_PORTS_STR + '$', dirent) if matches: bus_str = readattr(dirent, 'busnum') if bus_str: busnum = float(bus_str) else: busnum = None dev_str = readattr(dirent, 'devnum') if dev_str: devnum = float(dev_str) else: devnum = None if busnum == bus_id and devnum == dev_id: return str(matches.groups()[1]) class TemperDevice(object): """ A TEMPer USB thermometer. """ def __init__(self, device, sensor_count=1): self.set_sensor_count(sensor_count) self._device = device self._bus = device.bus self._ports = getattr(device, 'port_number', None) if self._ports == None: self._ports = find_ports(device) self.set_calibration_data() try: # Try to trigger a USB permission issue early so the # user is not presented with seemingly unrelated error message. # https://github.com/padelt/temper-python/issues/63 productname = self._device.product except ValueError as e: if 'langid' in str(e): raise usb.core.USBError("Error reading langids from device. "+ "This might be a permission issue. Please check that the device "+ "node for your TEMPer devices can be read and written by the "+ "user running this code. The temperusb README.md contains hints "+ "about how to fix this. Search for 'USB device permissions'.") config = DEVICE_LIBRARY.get(productname) if config is None: LOGGER.warning( "Unrecognised sensor type '%s'. " "Trying to guess communication format. " "Please add the configuration to 'device_library.py' " "and submit to %s to benefit other users." % (self._device.product, CONTRIBUTE_URL) ) config = DEVICE_LIBRARY["generic_fm75"] self.temp_sens_offsets = config.temp_sens_offsets self.hum_sens_offsets = config.hum_sens_offsets self.type = config.type self.set_sensor_count(self.lookup_sensor_count()) LOGGER.debug('Found device | Bus:{0} Ports:{1} SensorCount:{2}'.format( self._bus, self._ports, self._sensor_count)) def set_calibration_data(self, scale=None, offset=None): """ Set device calibration data based on settings in /etc/temper.conf. """ if scale is not None and offset is not None: self._scale = scale self._offset = offset elif scale is None and offset is None: self._scale = 1.0 self._offset = 0.0 try: f = open('/etc/temper.conf', 'r') except IOError: f = None if f: lines = f.read().split('\n') f.close() for line in lines: matches = re.match(CALIB_LINE_STR, line) if matches: bus = int(matches.groups()[0]) ports = matches.groups()[1] scale = float(matches.groups()[2]) offset = float(matches.groups()[3]) if (str(ports) == str(self._ports)) and (str(bus) == str(self._bus)): self._scale = scale self._offset = offset else: raise RuntimeError("Must set both scale and offset, or neither") def lookup_offset(self, sensor): """ Lookup the number of sensors on the device by product name. """ return self.temp_sens_offsets[sensor] def lookup_humidity_offset(self, sensor): """ Get the offset of the humidity data. """ if self.hum_sens_offsets: return self.hum_sens_offsets[sensor] else: return None def lookup_sensor_count(self): """ Lookup the number of sensors on the device by product name. """ return len(self.temp_sens_offsets) def get_sensor_count(self): """ Get number of sensors on the device. """ return self._sensor_count def set_sensor_count(self, count): """ Set number of sensors on the device. To do: revamp /etc/temper.conf file to include this data. """ # Currently this only supports 1 and 2 sensor models. # If you have the 8 sensor model, please contribute to the # discussion here: https://github.com/padelt/temper-python/issues if count not in [1, 2, 3]: raise ValueError('Only sensor_count of 1-3 supported') self._sensor_count = int(count) def get_product(self): """ Get device product name. """ return self._device.product def get_ports(self): """ Get device USB ports. """ if self._ports: return self._ports return '' def get_bus(self): """ Get device USB bus. """ if self._bus: return self._bus return '' def get_data(self, reset_device=False): """ Get data from the USB device. """ try: if reset_device: self._device.reset() # detach kernel driver from both interfaces if attached, so we can set_configuration() for interface in [0,1]: if self._device.is_kernel_driver_active(interface): LOGGER.debug('Detaching kernel driver for interface %d ' 'of %r on ports %r', interface, self._device, self._ports) self._device.detach_kernel_driver(interface) self._device.set_configuration() # Prevent kernel message: # "usbfs: process (python) did not claim interface x before use" # This will become unnecessary once pull-request #124 for # PyUSB has been accepted and we depend on a fixed release # of PyUSB. Until then, and even with the fix applied, it # does not hurt to explicitly claim the interface. usb.util.claim_interface(self._device, INTERFACE) # Turns out we don't actually need that ctrl_transfer. # Disabling this reduces number of USBErrors from ~7/30 to 0! #self._device.ctrl_transfer(bmRequestType=0x21, bRequest=0x09, # wValue=0x0201, wIndex=0x00, data_or_wLength='\x01\x01', # timeout=TIMEOUT) # Magic: Our TEMPerV1.4 likes to be asked twice. When # only asked once, it get's stuck on the next access and # requires a reset. self._control_transfer(COMMANDS['temp']) self._interrupt_read() # Turns out a whole lot of that magic seems unnecessary. #self._control_transfer(COMMANDS['ini1']) #self._interrupt_read() #self._control_transfer(COMMANDS['ini2']) #self._interrupt_read() #self._interrupt_read() # Get temperature self._control_transfer(COMMANDS['temp']) temp_data = self._interrupt_read() # Get humidity LOGGER.debug("ID='%s'" % self._device.product) if self.hum_sens_offsets: humidity_data = temp_data else: humidity_data = None # Combine temperature and humidity data data = {'temp_data': temp_data, 'humidity_data': humidity_data} # Be a nice citizen and undo potential interface claiming. # Also see: https://github.com/walac/pyusb/blob/master/docs/tutorial.rst#dont-be-selfish usb.util.dispose_resources(self._device) return data except usb.USBError as err: if not reset_device: LOGGER.warning("Encountered %s, resetting %r and trying again.", err, self._device) return self.get_data(True) # Catch the permissions exception and add our message if "not permitted" in str(err): raise Exception( "Permission problem accessing USB. " "Maybe I need to run as root?") else: LOGGER.error(err) raise def get_temperature(self, format='celsius', sensor=0): """ Get device temperature reading. """ results = self.get_temperatures(sensors=[sensor,]) if format == 'celsius': return results[sensor]['temperature_c'] elif format == 'fahrenheit': return results[sensor]['temperature_f'] elif format == 'millicelsius': return results[sensor]['temperature_mc'] else: raise ValueError("Unknown format") def get_temperatures(self, sensors=None): """ Get device temperature reading. Params: - sensors: optional list of sensors to get a reading for, examples: [0,] - get reading for sensor 0 [0, 1,] - get reading for sensors 0 and 1 None - get readings for all sensors """ _sensors = sensors if _sensors is None: _sensors = list(range(0, self._sensor_count)) if not set(_sensors).issubset(list(range(0, self._sensor_count))): raise ValueError( 'Some or all of the sensors in the list %s are out of range ' 'given a sensor_count of %d. Valid range: %s' % ( _sensors, self._sensor_count, list(range(0, self._sensor_count)), ) ) data = self.get_data() data = data['temp_data'] results = {} # Interpret device response for sensor in _sensors: offset = self.lookup_offset(sensor) if self.type == TemperType.SI7021: celsius = struct.unpack_from('>h', data, offset)[0] * 175.72 / 65536 - 46.85 else: # fm75 (?) type device celsius = struct.unpack_from('>h', data, offset)[0] / 256.0 # Apply scaling and offset (if any) celsius = celsius * self._scale + self._offset LOGGER.debug("T=%.5fC" % celsius) results[sensor] = { 'ports': self.get_ports(), 'bus': self.get_bus(), 'sensor': sensor, 'temperature_f': celsius * 1.8 + 32.0, 'temperature_c': celsius, 'temperature_mc': celsius * 1000, 'temperature_k': celsius + 273.15, } return results def get_humidity(self, sensors=None): """ Get device humidity reading. Params: - sensors: optional list of sensors to get a reading for, examples: [0,] - get reading for sensor 0 [0, 1,] - get reading for sensors 0 and 1 None - get readings for all sensors """ _sensors = sensors if _sensors is None: _sensors = list(range(0, self._sensor_count)) if not set(_sensors).issubset(list(range(0, self._sensor_count))): raise ValueError( 'Some or all of the sensors in the list %s are out of range ' 'given a sensor_count of %d. Valid range: %s' % ( _sensors, self._sensor_count, list(range(0, self._sensor_count)), ) ) data = self.get_data() data = data['humidity_data'] results = {} # Interpret device response for sensor in _sensors: offset = self.lookup_humidity_offset(sensor) if offset is None: continue if self.type == TemperType.SI7021: humidity = (struct.unpack_from('>H', data, offset)[0] * 125) / 65536 -6 else: #fm75 (?) type device humidity = (struct.unpack_from('>H', data, offset)[0] * 32) / 1000.0 LOGGER.debug("RH=%.5f%%" % humidity) results[sensor] = { 'ports': self.get_ports(), 'bus': self.get_bus(), 'sensor': sensor, 'humidity_pc': humidity, } return results def _control_transfer(self, data): """ Send device a control request with standard parameters and as payload. """ LOGGER.debug('Ctrl transfer: %r', data) self._device.ctrl_transfer(bmRequestType=0x21, bRequest=0x09, wValue=0x0200, wIndex=0x01, data_or_wLength=data, timeout=TIMEOUT) def _interrupt_read(self): """ Read data from device. """ data = self._device.read(ENDPOINT, REQ_INT_LEN, timeout=TIMEOUT) LOGGER.debug('Read data: %r', ' '.join('{:02x}'.format(x) for x in data)) return data def close(self): """Does nothing in this device. Other device types may need to do cleanup here.""" pass class TemperHandler(object): """ Handler for TEMPer USB thermometers. """ def __init__(self): self._devices = [] for vid, pid in VIDPIDS: self._devices += [TemperDevice(device) for device in \ usb.core.find(find_all=True, idVendor=vid, idProduct=pid)] LOGGER.info('Found {0} TEMPer devices'.format(len(self._devices))) def get_devices(self): """ Get a list of all devices attached to this handler """ return self._devices