diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..41fcd25 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,5 @@ +FROM mcr.microsoft.com/devcontainers/base:alpine-3.18 + +RUN apk add --no-cache \ + libusb=1.0.26-r2 \ + py3-pip=23.1.2-r0 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b8c8fea --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/alpine +{ + "name": "Python Alpine", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip3 install --user -r requirements.txt -r requirements_test.txt", + + // Priviledged mode is necessary to get access to usb + "runArgs": ["--privileged"], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + //"remoteUser": "root" + + // Access local .pypi api keys + "mounts": [ + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.pypirc,target=/home/vscode/.pypirc,type=bind,consistency=cached" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..355f7c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_test.txt + - name: Run unit tests + run: python -m pytest --import-mode=append tests/ + diff --git a/.gitignore b/.gitignore index ff49171..3db74a3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ nosetests.xml # virtualenv _/ +.idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..77694e9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "python.envFile": "${workspaceFolder}/.env", + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.nosetestsEnabled": false, + "python.testing.pytestEnabled": true, + "python.pythonPath": "/usr/local/bin/python", +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c30f2f5..155de48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ All notable changes to this project will be documented in this file. No changes yet. +## [1.6.1] - 2023-12-19 +### Added +- Support for TEMPer2V1.3 +- Support for TEMPerHumiV1.1 +- Support for TEMPerHumiV1.0 +- Experimental support for TEMPer2_V3.7 +- get_product() function to get product name +- Updates to documentation + +## [1.6.0] - 2021-11-03 +### Added +- A new architecture for supporting different device types. +- Tests using pytest + +### Added +- Add support for 3 sensor tempers and TEMPerNTC1.O +- Add support for TemperHUM with si7021 type sensor +- Add support for TEMPer1V1.4 + +### Fixed +- Fixes for the munin plugin +- Report TEMPerV1.2 devices as having a single sensor +- Fix error message about USB permissions to display correctly on Python 3.6 + ## [1.5.3] - 2017-04-03 - Commit ID: 4da8be1 ### Added - Support for 0c45:7402 (RDing TEMPer1F_H1_V1.4) including humidity diff --git a/README.md b/README.md index 20b6e14..ea57c0d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,11 @@ Under Debian/Ubuntu, treat yourself to some package goodness: # Installation and usage -Clone the repository, cd into its top-level directory, and run +To install using pip, run + + pip install temperusb + +To install from source, clone the repository, cd into its top-level directory, and run sudo python setup.py install @@ -37,7 +41,7 @@ you should end up with two scripts conveniently installed: /usr/local/bin/temper-poll /usr/local/bin/temper-snmp -If your system does not provide access as a normal user to the USB device, you need to rum them as root. See "USB device permissions" section for more on this. +If your system does not provide access as a normal user to the USB device, you need to run them as root. See "USB device permissions" section for more on this. temper-poll accepts -p option now, which adds the USB bus and port information each device is plugged on. @@ -53,7 +57,7 @@ with -p option Found 1 devices Device #0 (bus 1 - port 1.3): 22.4°C 72.3°F -Which tells you there is a a USB hub plugged (internally or externally) on the port 1 of the bus 1 of the host, and your TEMPer device is on the port 3 of that hub. +Which tells you there is a USB hub plugged (internally or externally) on the port 1 of the bus 1 of the host, and your TEMPer device is on the port 3 of that hub. ## Tell kernel to leave TEMPer alone @@ -104,6 +108,8 @@ specific USB devices (with matching VID/PID) by anyone. Install like this: sudo cp etc/99-tempsensor.rules /etc/udev/rules.d/ +Then restart. + To check for success, find the bus and device IDs of the devices like this: pi@raspi-temper1 ~ $ lsusb | grep "0c45:7401" @@ -125,13 +131,13 @@ along with `snmpd`. ## What to add to snmpd.conf To emulate an APC Battery/Internal temperature value, add something like this to snmpd.conf. -The highest of all measured temperatures in degrees celcius as an integer is reported. +The highest of all measured temperatures in degrees Celsius as an integer is reported. pass_persist .1.3.6.1.4.1.318.1.1.1.2.2.2 /usr/local/bin/temper-snmp Alternatively, emulate a Cisco device's temperature information with the following. The first three detected devices will be reported as ..13.1.3.1.3.1, ..3.2 and ..3.3 . -The value is the temperature in degree celcius as an integer. +The value is the temperature in degree Celsius as an integer. pass_persist .1.3.6.1.4.1.9.9.13.1.3 /usr/local/bin/temper-snmp @@ -191,6 +197,74 @@ Try running it manually and mimik a passpersist-request (`->` means you should e If you have a problem with the USB side and want to test SNMP, run the script with `--testmode`. +# Using MQTT +While temper-python does not directly support MQTT, it is fairly straightforeward to push the temperature values collected to a MQTT broker periodically, so they may be integrated in for example Home-Assistant. + +In the below example we will show how to push data to a Mosquitto MQTT broker using a small bash script and a CRON job. The setup was tested with temper-python installed on a RaspberryPi running Rasbian Buster and a Mosquitto MQTT broker installed as part of Home-Assistant. + +In this example we will publish one specific temperature value for one specific device, for example the temperatue in Celcius for device 0 +To test this, type on your console: + + $ /usr/local/bin/temper-poll -c -s 0 + 1.9 + +As you can see because of the "-c" option, temper-poll will present a single temperature value in degrees Celcius. To get degrees Farenheit, use option "-f" +The "-s 0" option makes sure temper-poll only looks at Device #0 + +We now need to install the Mosquitto client on the device where you installed temper-python. This will provide the mosquitto_pub client which we will use to push towards the MQTT broker + + sudo apt-get install mosquitto-clients + +To start pushing a value to your MQTT broker, you also need to know the MQTT server IP adress and optionally a username and password. +A mosquitto_pub command looks something like this: + + /usr/bin/mosquitto_pub -h MQTT_IP -m "Some message" -t MQTT_TOPIC -u MQTT_USERNAME -P MQTT_PASSWORD + +If you need more paramaters, have a look at the output of + + mosquitto_pub --help + +If needed, use the "-d" option for mosquitto_pub, which will print debug output about the connection. A successful connection debug print should look like: + + pi@raspberrypi:~ $ /usr/bin/mosquitto_pub -h 10.0.0.* -m "foobar" -t home-assistant/temper_schuur/temperature -u ****** -P ****** -d + Client mosqpub|2107-raspberryp sending CONNECT + Client mosqpub|2107-raspberryp received CONNACK (0) + Client mosqpub|2107-raspberryp sending PUBLISH (d0, q0, r0, m1, 'home-assistant/temper_schuur/temperature', ... (0 bytes)) + Client mosqpub|2107-raspberryp sending DISCONNECT + +We will now combine the two using a small bash script called "temper-push-mqtt". First create the script, then make it executable. + + sudo touch /usr/local/bin/temper-push-mqtt + sudo chmod a+x /usr/local/bin/temper-push-mqtt + sudo nano /usr/local/bin/temper-push-mqtt + +The script should contain: + + #! /bin/bash + T=$(/usr/local/bin/temper-poll -c -s 0) + /usr/bin/mosquitto_pub -h MQTT_IP -m "${T}" -t MQTT_TOPIC -u MQTT_USER -P MQTT_PASSWORD + +If you need other parameters for temper-poll, replace them here. Also replace all MQTT_* values with proper values for you local setup. +If you are using Home-Assistant you should add a sensor to you setup by defining it in configuration.yaml: + + sensor: + - platform: mqtt + name: "Temperatuur Schuur" + state_topic: "home-assistant/temper_schuur/temperature" + unit_of_measurement: "°C" + +Make sure the state_topic value matches the MQTT_TOPIC value in the temper-push-mqtt script + +Finally, to make sure we get periodic data, we create a cron job to run the script every 5 minutes + + sudo crontab -e + +To start a new crontab, which should contain + + */5 * * * * /usr/local/bin/temper-push-mqtt > /var/log/cron_temper-push-mqtt.log 2>&1 + +The above cronjob will run the temper-push-mqtt script every 5 minutes and will log any issues to a logfile /var/log/cron_temper-push-mqtt.log + # Note on multiple device usage The devices I have seen do not have any way to identify them. The serial number is 0. @@ -211,7 +285,7 @@ any plugging on the device. Even then, you are not safe. Sorry. ## Note by GM3D -Since calibration parameters must be set per each device, we need some way to identify them physically. As mentioned above, the serial number for all TEMPer devices is zero, so there is no true way to tell which is which programatically. The USB device number does not work either since it changes every time you reboot the machine or plug/unplug the device. The way that possibly can work is identifying them by the combination of the bus number and the USB port (possibly a chain of ports, if you have hubs in between), which is what I am doing for now. +Since calibration parameters must be set per each device, we need some way to identify them physically. As mentioned above, the serial number for all TEMPer devices is zero, so there is no true way to tell which is which programmatically. The USB device number does not work either since it changes every time you reboot the machine or plug/unplug the device. The way that possibly can work is identifying them by the combination of the bus number and the USB port (possibly a chain of ports, if you have hubs in between), which is what I am doing for now. This information is basically the same with what you can get with `lsusb -t` and is based on the information in the sysfs directory `/sys/bus/usb/devices` (see below). So far I am assuming this scheme is persistent enough for regular use cases, but even the bus number may change in some cases like - for example - if your machine is a tablet like machine and you hotplug it to a keyboard dock with a USB root hub in it. In such case you will need to re-run `lsusb` and adjust the bus-port numbers in the configuration file accordingly. At the moment I have no clue about SNMP OID persistence. @@ -273,8 +347,7 @@ as seen on [Google+](https://plus.google.com/105569853186899442987/posts/N9T7xAj # Compatibility with Python versions -This should work on both Python 2 and 3. It was tested with Python 2.7.3 and 3.2.3. -The `snmp_passpersist` mode is Python 2 only because the upstream package is not ready yet. +This should work on Python 3.8 and above. It was tested with Python 3.8, 3.9, 3.10, 3.11, 3.12, 3.13. # Authors @@ -286,3 +359,4 @@ The `snmp_passpersist` mode is Python 2 only because the upstream package is not * Reduced kernel messages, support multiple sensors, and support TEMPer1F_V1.3 by Philip Jay (@ps-jay on Github) * Python 3 compatibility and rewrite of cli.py to use argparse by Will Furnass (@willfurnass on Github) * TEMPerV1.4 support by Christian von Roques (@roques on Github) +* Pytest and architecture improvement by Dave Thompson (@davet2001 on Github). diff --git a/etc/99-tempsensor.rules b/etc/99-tempsensor.rules index 037a343..6a8986d 100644 --- a/etc/99-tempsensor.rules +++ b/etc/99-tempsensor.rules @@ -1,2 +1,3 @@ SUBSYSTEMS=="usb", ACTION=="add", ATTRS{idVendor}=="0c45", ATTRS{idProduct}=="7401", MODE="666" SUBSYSTEMS=="usb", ACTION=="add", ATTRS{idVendor}=="0c45", ATTRS{idProduct}=="7402", MODE="666" +SUBSYSTEMS=="usb", ACTION=="add", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="e025", MODE="666" diff --git a/etc/munin-temperature b/etc/munin-temperature index d914247..de54f42 100755 --- a/etc/munin-temperature +++ b/etc/munin-temperature @@ -9,6 +9,7 @@ #%# capabilities=autoconf +from __future__ import print_function import sys @@ -21,23 +22,23 @@ def autoconf(): try: handler = get_handler() except ImportError: - print "no (temper-python package is not installed)" + print ("no (temper-python package is not installed)") else: if len(handler.get_devices()): - print "yes" + print ("yes") else: - print "no (No devices found)" + print ("no (No devices found)") def config(): handler = get_handler() - print "graph_title Temperature" - print "graph_vlabel Degrees Celsius" - print "graph_category sensors" + print ("graph_title Temperature") + print ("graph_vlabel Degrees Celsius") + print ("graph_category sensors") for device in handler.get_devices(): port = device.get_ports() port_name = str(port).replace('.', '_') - print "temp_" + port_name + ".label Port {0:s} Temperature".format(str(port)) + print ("temp_" + port_name + ".label Port {0:s} Temperature".format(str(port))) def fetch(): @@ -49,7 +50,7 @@ def fetch(): temp = device.get_temperature() except Exception: temp = 'U' - print "temp_" + port_name + ".value {0:f}".format(temp) + print ("temp_" + port_name + ".value {0:f}".format(temp)) def main(): diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e2effa --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyusb==1.2.1 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..8900955 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,4 @@ +# Dependencies for running tests. +pyusb==1.2.1 +pytest==7.4.3 + diff --git a/scripts/publish_to_pypi.sh b/scripts/publish_to_pypi.sh new file mode 100755 index 0000000..3d1b860 --- /dev/null +++ b/scripts/publish_to_pypi.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Script to automate publishing to pypi +# Dave T 2023-12-21 +pypi_config_file=~/.pypirc + +pip install twine + +if [ ! -f dist/*.tar.gz ]; then + echo "No releases found. Please run python3 -m setup.py sdist" + exit +fi +twine check dist/* + +echo "Ready to publish." +echo "Default is publishing to testpypi." +read -r -p "If you are fully ready, please publish to pypi by typing 'thisisnotatest': " response +echo "response=$response" +if [ "$response" = "thisisnotatest" ]; then + repository=pypi +else + repository=testpypi +fi + +if [ -f $pypi_config_file ]; then + echo "Using $pypi_config_file for API keys" +else + echo "$pypi_config_file not found, please paste pypi API token below:" + read twine_api_key + export TWINE_USERNAME=__token__ + export TWINE_PASSWORD=$twine_api_key +fi +echo "Publishing to $repository..." +twine upload --repository $repository dist/* +echo "Publishing complete!" +echo +echo "Don't forget to tag this release!" \ No newline at end of file diff --git a/setup.py b/setup.py index 83fdda2..b1cb6c9 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +from io import open from setuptools import setup setup( @@ -5,9 +6,10 @@ author='Philipp Adelt', author_email='autosort-github@philipp.adelt.net ', url='https://github.com/padelt/temper-python', - version='1.5.3', + version='1.6.1', description='Reads temperature from TEMPerV1 devices (USB 0c45:7401)', long_description=open('README.md', encoding='utf-8').read(), + long_description_content_type='text/markdown', packages=['temperusb'], install_requires=[ 'pyusb>=1.0.0rc1', @@ -22,7 +24,6 @@ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', ], ) diff --git a/temperusb/cli.py b/temperusb/cli.py index 17b41bc..daaa866 100644 --- a/temperusb/cli.py +++ b/temperusb/cli.py @@ -7,7 +7,7 @@ def parse_args(): - descr = "Temperature data from a TEMPer v1.2 sensor." + descr = "Temperature data from a TEMPer v1.2/v1.3 sensor." parser = argparse.ArgumentParser(description=descr) parser.add_argument("-p", "--disp_ports", action='store_true', @@ -24,6 +24,8 @@ def parse_args(): "(multisensor devices only)", default='0') parser.add_argument("-S", "--sensor_count", type=int, help="Override auto-detected number of sensors on the device") + parser.add_argument("-v", "--verbose", action='store_true', + help="Verbose: display all debug information") args = parser.parse_args() return args @@ -32,8 +34,10 @@ def parse_args(): def main(): args = parse_args() quiet = args.celsius or args.fahrenheit or args.humidity - - logging.basicConfig(level = logging.ERROR if quiet else logging.WARNING) + lvl = logging.ERROR if quiet else logging.WARNING + if args.verbose: + lvl = logging.DEBUG + logging.basicConfig(level = lvl) th = TemperHandler() devs = th.get_devices() diff --git a/temperusb/device_library.py b/temperusb/device_library.py new file mode 100644 index 0000000..90cdebb --- /dev/null +++ b/temperusb/device_library.py @@ -0,0 +1,116 @@ +# encoding: utf-8 +# +# TEMPer USB temperature/humidty sensor device driver settings. +# 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. + +from enum import Enum + +class TemperType(Enum): + FM75 = 0 + SI7021 = 1 + +class TemperConfig: + def __init__( + self, + temp_sens_offsets: list, + hum_sens_offsets: list = None, + type: TemperType = TemperType.FM75, + ): + self.temp_sens_offsets = temp_sens_offsets + self.hum_sens_offsets = hum_sens_offsets + self.type = type + + +DEVICE_LIBRARY = { + "TEMPer2V1.3": TemperConfig( + temp_sens_offsets=[2, 4], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPerV1.2": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPerV1.4": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPer1F_V1.3": TemperConfig( + # Has only 1 sensor at offset 4 + temp_sens_offsets=[4], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPERHUM1V1.2": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=[4], + type=TemperType.SI7021, + ), + "TEMPERHUM1V1.3": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=[4], + type=TemperType.SI7021, + ), + "TEMPerHumiV1.0": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=[4], + type=TemperType.FM75, + ), + "TEMPerHumiV1.1": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=[4], + type=TemperType.FM75, + ), + "TEMPer1F_H1_V1.4": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=[4], + type=TemperType.FM75, + ), + "TEMPerNTC1.O": TemperConfig( + temp_sens_offsets=[2, 4, 6], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPer1V1.4": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPer2_M12_V1.3": TemperConfig( + temp_sens_offsets=[2, 4], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPer2_V3.7": TemperConfig( + temp_sens_offsets=[2, 10], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPer2V1.4": TemperConfig( + temp_sens_offsets=[2], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + "TEMPer2_M12_V1.3": TemperConfig( + temp_sens_offsets=[2, 4], + hum_sens_offsets=None, + type=TemperType.FM75, + ), + # The config used if the sensor type is not recognised. + # If your sensor is working but showing as unrecognised, please + # add a new entry above based on "generic_fm75" below, and submit + # a PR to https://github.com/padelt/temper-python/pulls + "generic_fm75": TemperConfig( + temp_sens_offsets=[2, 4], + hum_sens_offsets=None, + type=TemperType.FM75, + ), +} diff --git a/temperusb/snmp.py b/temperusb/snmp.py index 2501dfb..a59ab03 100644 --- a/temperusb/snmp.py +++ b/temperusb/snmp.py @@ -3,7 +3,7 @@ # Run snmp_temper.py as a pass-persist module for NetSNMP. # See README.md for instructions. # -# Copyright 2012-2014 Philipp Adelt +# Copyright 2012-2020 Philipp Adelt # # This code is licensed under the GNU public license (GPL). See LICENSE.md for details. diff --git a/temperusb/temper.py b/temperusb/temper.py index 41bb430..d8c77a6 100644 --- a/temperusb/temper.py +++ b/temperusb/temper.py @@ -3,7 +3,7 @@ # Handles devices reporting themselves as USB VID/PID 0C45:7401 (mine also says # RDing TEMPerV1.2). # -# Copyright 2012-2016 Philipp Adelt and contributors. +# Copyright 2012-2020 Philipp Adelt and contributors. # # This code is licensed under the GNU public license (GPL). See LICENSE.md for # details. @@ -14,18 +14,21 @@ 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 = '^\s*(\d+)-(\d+(?:\.\d+)*)' +USB_PORTS_STR = r'^\s*(\d+)-(\d+(?:\.\d+)*)' CALIB_LINE_STR = USB_PORTS_STR +\ - '\s*:\s*scale\s*=\s*([+|-]?\d*\.\d+)\s*,\s*offset\s*=\s*([+|-]?\d*\.\d+)' + 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', @@ -33,6 +36,7 @@ '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): @@ -92,17 +96,32 @@ def __init__(self, device, sensor_count=1): # 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 - self.lookup_sensor_count() + productname = self._device.product except ValueError as e: - if 'langid' in e.message: + 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}'.format( - self._bus, self._ports)) + 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): """ @@ -138,35 +157,22 @@ def lookup_offset(self, sensor): """ Lookup the number of sensors on the device by product name. """ - if self._device.product == 'TEMPer1F_V1.3': - # Has only 1 sensor, and it's at offset = 4 - return 4 - - # All others follow this pattern - if not, contribute here: https://github.com/padelt/temper-python/issues - # Sensor 0 = Offset 2 - # Sensor 1 = Offset 4 - return (sensor + 1) * 2 + return self.temp_sens_offsets[sensor] def lookup_humidity_offset(self, sensor): """ - Lookup the offset of the humidity data by product name. + Get the offset of the humidity data. """ - if self._device.product == 'TEMPer1F_H1_V1.4': - # Has only 1 sensor, and the humidity data is at offset = 4 - return 4 - - return None + 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. """ - if (self._device.product == 'TEMPer1F_V1.3') or \ - (self._device.product == 'TEMPer1F_H1_V1.4'): - return 1 - - # All others are two - if not the case, contribute here: https://github.com/padelt/temper-python/issues - return 2 + return len(self.temp_sens_offsets) def get_sensor_count(self): """ @@ -183,11 +189,17 @@ def set_sensor_count(self, count): # 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,]: - raise ValueError('Only sensor_count of 1 or 2 supported') + 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. @@ -229,11 +241,11 @@ def get_data(self, reset_device=False): # 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) + # 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 @@ -254,7 +266,8 @@ def get_data(self, reset_device=False): temp_data = self._interrupt_read() # Get humidity - if self._device.product == 'TEMPer1F_H1_V1.4': + LOGGER.debug("ID='%s'" % self._device.product) + if self.hum_sens_offsets: humidity_data = temp_data else: humidity_data = None @@ -327,9 +340,13 @@ def get_temperatures(self, sensors=None): # Interpret device response for sensor in _sensors: offset = self.lookup_offset(sensor) - celsius = struct.unpack_from('>h', data, offset)[0] / 256.0 + 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(), @@ -365,10 +382,8 @@ def get_humidity(self, sensors=None): list(range(0, self._sensor_count)), ) ) - data = self.get_data() data = data['humidity_data'] - results = {} # Interpret device response @@ -376,7 +391,11 @@ def get_humidity(self, sensors=None): offset = self.lookup_humidity_offset(sensor) if offset is None: continue - humidity = (struct.unpack_from('>H', data, offset)[0] * 32) / 1000.0 + 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(), @@ -400,7 +419,7 @@ def _interrupt_read(self): Read data from device. """ data = self._device.read(ENDPOINT, REQ_INT_LEN, timeout=TIMEOUT) - LOGGER.debug('Read data: %r', data) + LOGGER.debug('Read data: %r', ' '.join('{:02x}'.format(x) for x in data)) return data def close(self): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_temper.py b/tests/test_temper.py new file mode 100644 index 0000000..e0c8a59 --- /dev/null +++ b/tests/test_temper.py @@ -0,0 +1,205 @@ +""" +pytests for temperusb + +run from the project root with: +pytest --cov=temperusb --cov-report term-missing +""" + +import os +import pytest +import usb +from unittest.mock import MagicMock, patch, Mock + +import temperusb +from temperusb.temper import TIMEOUT + + +@pytest.mark.parametrize( + [ + "productname", # the faked usb device product name + "vid", # faked vendor ID + "pid", # faked vendor ID + "count", # number of sensors we expect to be reported + "ctrl_data_in_expected", # the ctrl data we expect to be sent to the (faked) usb device + "data_out_raw", # the bytes that the usb device will return (our encoded temps/RHs need to be in here) + "temperature_out_expected", # array of temperatures that we are expecting to see decoded. + "humidity_out_expected", # array of humidities that we are expecting to see decoded + ], + [ + [ + "generic_unmatched", # Default is to assume 2 fm75 style temperature sensors + 0x0C45, + 0x7401, + 2, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A\x2B\x33", # 0x201A,0x2B33 converts to 32.1C, 43.2C (fm75) + [32.1, 43.2], + None, + ], + [ + 'TEMPer2V1.3', + 0x0c45, + 0x7401, + 2, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x80\x04\x0a\xe0\x15\x00\x1d\x15", # 0x0AE0, 0x1500 converts to 10.9C, 21.0C (fm75) + [10.9, 21.0], + None, + ], + [ + "TEMPerV1.2", + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A", # 0x201A converts to 32.1C (fm75) + [32.1], + None, + ], + [ + "TEMPerV1.4", + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A", # 0x201A converts to 32.1C (fm75) + [32.1], + None, + ], + [ + "TEMPer2_M12_V1.3", + 0x0C45, + 0x7401, + 2, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A\x2B\x33", # 0x201A,0x2B33 converts to 32.1C, 43.2C (fm75) + [32.1, 43.2], + None, + ], + [ + "TEMPer1F_V1.3", # Has 1 sensor at offset 4 + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x00\x00\x20\x1A", # 0x201A converts to 32.1C (fm75) + [32.1], + None, + ], + [ + "TEMPERHUM1V1.3", + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x56\x2C\xBF\xB1", # 0x562C,0xBFB1 converts to 12.3C,87.6% (si7021) + [12.3], + [87.6], + ], + [ + "TEMPerHumiV1.0", + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A\x0C\x0C", # 0x201A,0x0C0C converts to 32.1C,98.7% (fm75) + [32.1], + [98.7], + ], + [ + "TEMPerHumiV1.1", + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A\x0C\x0C", # 0x201A,0x0C0C converts to 32.1C,98.7% (fm75) + [32.1], + [98.7], + ], + [ + "TEMPer1F_H1_V1.4", + 0x0C45, + 0x7401, + 1, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A\x0C\x0C", # 0x201A,0x0C0C converts to 32.1C,98.7% (fm75) + [32.1], + [98.7], + ], + [ + "TEMPerNTC1.O", + 0x0C45, + 0x7401, + 3, + b"\x01\x80\x33\x01\x00\x00\x00\x00", + b"\x00\x00\x20\x1A\x2B\x33\x36\x4D", # 0x201A,0x2B33,0x364D converts to 32.1,43.2,54.3C (fm75) + [32.1, 43.2, 54.3], + None, + ], + ], +) +def test_TemperDevice( + productname, + vid, + pid, + count, + ctrl_data_in_expected, + data_out_raw, + temperature_out_expected, + humidity_out_expected, +): + """ + Patches the underlying usb port call to allow us to verify the data + we would be sending, and fake the return data so that we can test the + conversion coming back. + """ + usbdev = Mock(bus="fakebus", product=productname) + usbdev.is_kernel_driver_active = MagicMock(return_value=False) + + def ctrl_transfer_dummy( + bmRequestType, bRequest, wValue, wIndex, data_or_wLength, timeout + ): + assert data_or_wLength == ctrl_data_in_expected + assert timeout == TIMEOUT + + usbdev.ctrl_transfer = MagicMock( + bmRequestType=0x21, + bRequest=0x09, + wValue=0x0200, + wIndex=0x01, + data_or_wLength=None, + timeout=None, + side_effect=ctrl_transfer_dummy, + ) + usbdev.read = Mock(return_value=data_out_raw) + + def match_pids(find_all, idVendor, idProduct): + if idVendor == vid and idProduct == pid: + return [usbdev] + else: + return [] + + with patch("usb.core.find", side_effect=match_pids, return_value=[usbdev]): + th = temperusb.TemperHandler() + devs = th.get_devices() + # Check that we actually got any devices + assert devs != None + # Check that we only found one sensor + assert len(devs) == 1, "Should be only one sensor type matching" + + dev = devs[0] + + # check that the sensor count reported is what we expect + assert dev.get_sensor_count() == count + # read a temperature + results = dev.get_temperatures(None) + + for i, temperature in enumerate(temperature_out_expected): + # check the temperature is what we were expecting. + assert results[i]["temperature_c"] == pytest.approx(temperature, 0.01) + + # if the device is expected to also report humidty + if humidity_out_expected: + for i, humidity in enumerate(humidity_out_expected): + results_h = dev.get_humidity(None) + assert results_h[i]["humidity_pc"] == pytest.approx(humidity, 0.1)