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 b948985..3db74a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,40 @@ *.swp -*.pyc +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# 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 new file mode 100644 index 0000000..155de48 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Change log +All notable changes to this project will be documented in this file. + +* This project adheres to [Semantic Versioning](http://semver.org/). +* This project follows the guidelines outlined on [keepachangelog.com](http://keepachangelog.com/). + +## [Unreleased] + +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 +- Hints for local development +- Add release documentation to `DEVELOPMENT.md`. +### Fixed +- Negative temperature readings incorrectly wrapped around to very high temperatures +- Fixed format string error in the munin plugin (PR#71) + +## [1.5.2] - 2016-09-07 - Commit ID: e904dbe +### Fixed +- Clarification of install documentation from eric-s-raymond. +- Workaround for misleading error message when at least one TEMPer USB device node has insufficient permissions. (#63) + +## [1.5.1] - 2016-06-12 - Commit ID: ceb0617 +### Added +- Support for `TEMPer1F_V1.3`'s behaviour: Only one sensor, data is at offset 4 from ps-jay. + +### Fixed +- Comparing only port without bus may lead to calibration being applied to multiple devices instead of one from ps-jay. + +## [1.5.0] - 2016-04-20 - Commit ID: 8752b14 +### Added +- Changelog file. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..f205cc2 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,169 @@ +For development purposes, you will sometimes need to change some code and try it. +This should happen without changing the main installation of `temper-python`. +Here is how to do it. + +You will need these tools installed: + +- `git` +- `python` (if you don't know which, grab Python 3) +- `virtualenv` for the Python version (see below) + +# Clone the repository + +This will clone into a directory named `temper-dev`: + +``` +pa@plug2:~/temper$ git clone https://github.com/padelt/temper-python.git temper-dev +Cloning into 'temper-dev'... +remote: Counting objects: 544, done. +Receiving objects: 100% (544/544), 118.51 KiB, done. +remote: Total 544 (delta 0), reused 0 (delta 0), pack-reused 544 +Resolving deltas: 100% (329/329), done. +pa@plug2:~/temper$ cd temper-dev/ +pa@plug2:~/temper/temper-dev$ +``` + +# How to find `virtualenv` + +A virtualenv basically isolates all the package installation we are going to do +in a subdirectory instead of the global python repository. + +Unfortunately, availability of virtualenv differs greatly between Python versions. + +In Python 2 and until 3.3, this is a seperate tool, usually installed from your distribution +packages and available as a binary named `virtualenv` (check availability using +`which virtualenv`). + +In Python 3.4+, we finally reached a sane solution: Virtualenv is a module in +the standard Python distribution and is called using `python -m venv` followed +by your desire virtualenv directory. + +# Setting up a `virtualenv` and activating it + +Check which python binary is available and what you want by entering `python` +and hitting the Tab key twice to have your shell suggest some: + +``` +pa@plug2:~/temper/temper-dev$ python +python python2.7 python3 python3.2mu python-config +python2 python2.7-config python3.2 python3mu +``` + +I will choose `python3.2`. + + +To have it set up in the subdirectory `venv` (the name could be any valid +directory name), try this: + +``` +pa@plug2:~/temper/temper-dev$ virtualenv -p python3.2 venv +Running virtualenv with interpreter /usr/bin/python3.2 +New python executable in venv/bin/python3.2 +Also creating executable in venv/bin/python +Installing setuptools, pip, wheel...done. +pa@plug2:~/temper/temper-dev$ ll venv/bin/ +insgesamt 2792 +-rw-r--r-- 1 pa pa 2242 Dez 5 11:34 activate +-rw-r--r-- 1 pa pa 1268 Dez 5 11:34 activate.csh +-rw-r--r-- 1 pa pa 2481 Dez 5 11:34 activate.fish +-rw-r--r-- 1 pa pa 1137 Dez 5 11:34 activate_this.py +-rwxr-xr-x 1 pa pa 262 Dez 5 11:34 easy_install +-rwxr-xr-x 1 pa pa 262 Dez 5 11:34 easy_install-3.2 +-rwxr-xr-x 1 pa pa 234 Dez 5 11:34 pip +-rwxr-xr-x 1 pa pa 234 Dez 5 11:34 pip3 +-rwxr-xr-x 1 pa pa 234 Dez 5 11:34 pip3.2 +lrwxrwxrwx 1 pa pa 9 Dez 5 11:34 python -> python3.2 +lrwxrwxrwx 1 pa pa 9 Dez 5 11:34 python3 -> python3.2 +-rwxr-xr-x 1 pa pa 2814320 Dez 5 11:34 python3.2 +-rwxr-xr-x 1 pa pa 241 Dez 5 11:34 wheel +pa@plug2:~/temper/temper-dev$ +``` + +Now activate it: + +``` +pa@plug2:~/temper/temper-dev$ . venv/bin/activate +(venv)pa@plug2:~/temper/temper-dev$ +``` + +What this does is prepend your PATH environment variable to prefer the python +executable in the virtualenv. All the installations using `pip` will now go +there and not into your global python repo. + +To later deactivate it, run `deactivate` (which is a function set into your +running `bash` by `activate`). + +Check that the right python binary will be called: + +``` +(venv)pa@plug2:~/temper/temper-dev$ which python +/home/pa/temper/temper-dev/venv/bin/python +``` + +Great! + +# Install `temper-python` into the virtualenv + +``` +(venv)pa@plug2:~/temper/temper-dev$ python setup.py install +running install +... +Installing temper-poll script to /home/pa/temper/temper-dev/venv/bin +... +Finished processing dependencies for temperusb==1.5.2 +(venv)pa@plug2:~/temper/temper-dev$ +``` + +Now we can run `temper-poll` for testing. Since the virtualenv is active, +our fresh install is found first: +``` +(venv)pa@plug2:~/temper/temper-dev$ which temper-poll +/home/pa/temper/temper-dev/venv/bin/temper-poll +(venv)pa@plug2:~/temper/temper-dev$ temper-poll +Found 2 devices +Device #0: 30.9°C 87.7°F +Device #1: 17.1°C 62.8°F +(venv)pa@plug2:~/temper/temper-dev$ +``` + +# Development/testing workflow + +To test a change, you need to follow this workflow: + +- Make your changes to e.g. `temperusb/temper.py` +- Run `python setup.py install --force` (the `--force` will have it + reinstalled despite the package version in `setup.py` not changing). +- Run `temper-poll` + +This is a simple and surefire way to deal with module names and +dependencies. + +# Release workflow + +1. Edit `setup.py` to reflect the new version. +1. Edit `CHANGELOG.md` to document the new version (without commit ID). +1. Setup your `~.pypirc`: + ``` + [distutils] + index-servers = + pypi + pypitest + + [pypi] + repository=https://pypi.python.org/pypi + username=myusername + password=mypass + + [pypitest] + repository=https://testpypi.python.org/pypi + username=myusername + password=mypass + ```` +1. Test-Upload: `python setup.py sdist upload -r pypitest` +1. Check if https://testpypi.python.org/pypi/temperusb looks good. +1. Commit changes and note commit ID. +1. Tag the revision and push the tag to Github: + `git tag v1.5.3 && git push origin v1.5.3` +1. Edit `CHANGELOG.md` noting the commit ID you just tagged. +1. Commit and push that change. +1. Live PyPI upload: `python setup.py sdist upload -r pypi` diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bb3ec5f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/README.md b/README.md index 6d651d1..ea57c0d 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,81 @@ Also provides a passpersist-module for NetSNMP (as found in the `snmpd` packages of Debian and Ubuntu) to present the temperature of 1-3 USB devices via SNMP. +### Reported working devices + +| USB ID | Name Reported | Notes | +| ------------------------------------------------------------ | ------------------------ | ----------------------- | +| `0c45:7401 Microdia` | `RDing TEMPerV1.2` | First supported device | +| `0c45:7401 Microdia TEMPer Temperature Sensor` | `RDing TEMPer2_M12_V1.3` | Two sensor device | +| `0c45:7401 Microdia` | `RDing TEMPer1F_V1.3` | Single external sensor, but better precision is possible by using "sensor 2" | +| `0c45:7401 Microdia` | `RDing TEMPerV1.4` | | +| `0c45:7402 Microdia TEMPerHUM Temperature & Humidity Sensor` | `RDing TEMPer1F_H1_V1.4` | Single sensor which reports both temperature and relative-humidity | + # Requirements Basically, `libusb` bindings for python (PyUSB) and `snmp-passpersist` from PyPI. Under Debian/Ubuntu, treat yourself to some package goodness: - sudo apt-get install python-usb python-setuptools + sudo apt-get install python-usb python-setuptools snmpd # The latter is only necessary for SNMP-usage. sudo easy_install snmp-passpersist -# Usage +# Installation and usage + +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 + +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 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. + +without -p option + + $ temper-poll + Found 1 devices + Device #0: 22.5°C 72.5°F + +with -p option + + $ temper-poll -p + Found 1 devices + Device #0 (bus 1 - port 1.3): 22.4°C 72.3°F + +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 + +Regarding errors: + +- `usb.core.USBError: [Errno 16] Resource busy` +- `Unknown error` + +By default, the Linux kernel claims (e.g. opens/uses) the TEMPer device as a keyboard (HID device). +When that happens, this script is not able to set the configuration and communicate with it. -To print temperatures of all sensors found in the system, just run +You will see one of those two errors when running `sudo temper-poll`. Your `dmesg` log will show something similar to this: - python src/temper.py + usb 1-1.3: usbfs: interface 0 claimed by usbhid while 'temper-poll' sets config #1 -If your udev installation does not provide access as a normal user to the -USB device, you need to run it as root: +To prevent this, add this to the kernel command line: - sudo python src/temper.py + usbhid.quirks=0x0c45:0x7401:0x4 + +On Raspbian, this will be `/boot/cmdline.txt`. Reboot after saving and retry. Hat tip to and more information from [AndiDog here](http://unix.stackexchange.com/questions/55495/prevent-usbhid-from-claiming-usb-device). # Serving via SNMP -Using [NetSNMP](http://www.net-snmp.org/), you can use `src/snmp_temper.py` +Using [NetSNMP](http://www.net-snmp.org/), you can use `temper/snmp.py` as a `pass_persist` module. You can choose one of two OIDs to be emulated: [APC's typical](http://www.oidview.com/mibs/318/PowerNet-MIB.html) internal/battery temperature (.1.3.6.1.4.1.318.1.1.1.2.2.2.0) or @@ -54,7 +106,9 @@ You might find a corresponding note in syslog. To solve that, the file `99-tempsensor.rules` is a udev rule that allows access to the specific USB devices (with matching VID/PID) by anyone. Install like this: - sudo cp udev/99-tempsensor.rules /etc/udev/rules.d/ + 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: @@ -77,26 +131,30 @@ 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 /path/to/this/script/snmp_temper.py + 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 /path/to/this/script/snmp_temper.py + pass_persist .1.3.6.1.4.1.9.9.13.1.3 /usr/local/bin/temper-snmp -Add `--testmode` to the line (as an option to `snmp_temper.py` to enable a mode where +Add `--testmode` to the line (as an option to `snmp.py` to enable a mode where APC reports 99°C and Cisco OIDs report 97, 98 and 99°C respectively. No actual devices need to be installed but `libusb` and its Python bindings are still required. +The path `/usr/local/bin/` is correct if the installation using `python setup.py install` +did install the scripts there. If you prefer not to install them, find and use the +`temper/snmp.py` file. + ## Troubleshooting NetSNMP-interaction The error reporting of NetSNMP is underwhelming to say the least. Expect every error to fail silently without a chance to find the source. -`snmp_temper.py` reports some simple information to syslog with an ident string +`snmp.py` reports some simple information to syslog with an ident string of `temper-python` and a facility of `LOG_DAEMON`. So this should give you the available debug information: sudo tail -f /var/log/syslog | grep temper-python @@ -106,7 +164,7 @@ Try stopping the snmpd daemon and starting it with logging to the console: sudo service snmpd stop sudo snmpd -f -It will _not_ start the passpersist-process for `snmp_temper.py` immediately +It will _not_ start the passpersist-process for `snmp.py` immediately but on the first request for the activated OIDs. This also means that the first `snmpget` you try may fail like this: @@ -128,7 +186,7 @@ When NetSNMP starts the instance (upon first `snmpget`), you should see somethin If you don't even see this, maybe the script has a problem and quits with an exception. Try running it manually and mimik a passpersist-request (`->` means you should enter the rest of the line): - -> sudo src/snmp_temper.py + -> sudo temper/snmp.py -> PING <- PONG -> get @@ -139,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. @@ -157,7 +283,80 @@ belongs to what OID if you are using SNMP. Long story short: Only use the device order if the USB bus is stable and you reboot after 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 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. + +# Calibration parameters + +You can have parameters in the configuration file `/etc/temper.conf` for each of your TEMPer device to calibrate its value with simple linear formula. If there is not this file on your machine it's fine, calibration is just skipped. The same if the program can't find a matching line with the actual device on the system. + +Format of calibration lines in `/etc/temper.conf` is: + + n-m(.m)* : scale = a, offset = b + +where `n` is the USB bus number and `m` is (possibly a chain of) the USB port(s) +which your TEMPer device is plugged on. `a` and `b` are some floating values decided by experiment, we will come back to this later, first let me describe how n and m can be decided for your device. + +You will need to use `lsusb` command in usbutils package to decide `n` and `m`. Use `lsusb` with and without `-t` option. + +For example, assume the following outputs; + + $ lsusb + Bus 002 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub + Bus 001 Device 016: ID 0c45:7401 Microdia + Bus 001 Device 015: ID 1a40:0101 TERMINUS TECHNOLOGY INC. USB-2.0 4-Port HUB + Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub + + $ lsusb -t + /: Bus 02.Port 1: Dev 1, Class=root_hub, Driver=orion-ehci/1p, 480M + /: Bus 01.Port 1: Dev 1, Class=root_hub, Driver=orion-ehci/1p, 480M + |__ Port 4: Dev 15, If 0, Class=hub, Driver=hub/4p, 12M + |__ Port 3: Dev 16, If 0, Class=HID, Driver=usbhid, 1.5M + |__ Port 3: Dev 16, If 1, Class=HID, Driver=usbhid, 1.5M + +First output tells you your TEMPer device (0c45:7401 Microdia) is on the bus 1 and has (just currently, it may change time to time, even if you don't move it around) device ID = 16. + +Now look at the second output. Looking at this tree, your TEMPer device (Dev 16) on the bus 01 is connected to your pc through two ports, port 4 and port 3. +Don't worry about two devices having the same Dev ID = 16, they both belong to a single TEMPer device (it uses two USB interfaces by default, which is normal). + +So in this example, `n = 1` and `m = 4.3`; thus the config file should be like + + 1-4.3: scale = a, offset = b + +with `a` and `b` replaced with the actual values which you will need to measure and +calculate for your own TEMPer device. These values are used in the formula + + y = a * x + b + +where + +* `y`: calibrated temperature (in Celsius), +* `x`: raw temperature read from your TEMPer device (in Celsius). + +You will need to find appropriate values for `a` and `b` for your TEMPer device by doing some experiment and basic math. Either comparing it with another thermometer which you can rely on or measuring two temperatures which you already know ... like iced water and boiling water, but make sure in the latter case that you seal your TEMPer device firmly in a plastic bag or something, since it is NOT waterproof! + +To find out bus and port numbers, you can also try running temper-poll with -p option, which will contain information in the form (bus 1 - port 4.3) in the above example. This might be actually easier than looking at the `lsusb` outputs, as long as it works. + # Origins The USB interaction pattern is extracted from [here](http://www.isp-sl.com/pcsensor-1.0.0.tgz) as seen on [Google+](https://plus.google.com/105569853186899442987/posts/N9T7xAjEtyF). + +# Compatibility with Python versions + +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 + +* Original rewrite by Philipp Adelt +* Additional work by Brian Cline +* Calibration code by Joji Monma (@GM3D on Github) +* Munin plugin by Alexander Schier (@allo- on Github) +* PyPI package work and rewrite to `libusb1` by James Stewart (@amorphic on Github) +* 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 new file mode 100644 index 0000000..6a8986d --- /dev/null +++ b/etc/99-tempsensor.rules @@ -0,0 +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 new file mode 100755 index 0000000..de54f42 --- /dev/null +++ b/etc/munin-temperature @@ -0,0 +1,69 @@ +#!/usr/bin/python +# encoding: utf-8 +# +# munin-plugin for temper +# +# Copyright 2013 Alexander Schier +# +# This code is licensed under the GNU public license (GPL). See LICENSE.md for details. + +#%# capabilities=autoconf + +from __future__ import print_function +import sys + + +def get_handler(): + from temperusb.temper import TemperHandler + return TemperHandler() + + +def autoconf(): + try: + handler = get_handler() + except ImportError: + print ("no (temper-python package is not installed)") + else: + if len(handler.get_devices()): + print ("yes") + else: + print ("no (No devices found)") + + +def config(): + handler = get_handler() + 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))) + + +def fetch(): + handler = get_handler() + for device in handler.get_devices(): + port = device.get_ports() + port_name = str(port).replace('.', '_') + try: + temp = device.get_temperature() + except Exception: + temp = 'U' + print ("temp_" + port_name + ".value {0:f}".format(temp)) + + +def main(): + if len(sys.argv) == 2: + arg = sys.argv[1] + if arg == 'autoconf': + autoconf() + elif arg == 'config': + config() + else: + fetch() + sys.exit(0) + + +if __name__ == '__main__': + 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 b6b83c1..b1cb6c9 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,29 @@ -#!/usr/bin/env python - -from distutils.core import setup +from io import open +from setuptools import setup setup( - name='Temper', - version='1.0', - description='Python tool for reading temerature', - packages=["temper"] - ) + name='temperusb', + author='Philipp Adelt', + author_email='autosort-github@philipp.adelt.net ', + url='https://github.com/padelt/temper-python', + 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', + ], + entry_points={ + 'console_scripts': [ + 'temper-poll = temperusb.cli:main', + 'temper-snmp = temperusb.snmp:main' + ] + }, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Programming Language :: Python :: 3', + ], +) diff --git a/temper/__init__.py b/temper/__init__.py deleted file mode 100644 index d62bdce..0000000 --- a/temper/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from temper import TemperDevice, TemperHandler diff --git a/temper/temper.py b/temper/temper.py deleted file mode 100755 index bb2342c..0000000 --- a/temper/temper.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/python -# encoding: utf-8 -# -# Handles devices reporting themselves as USB VID/PID 0C45:7401 (mine also says RDing TEMPerV1.2). -# -# Copyright 2012, 2013 Philipp Adelt -# -# This code is licensed under the GNU public license (GPL). See LICENSE.md for details. - -import usb -import sys -import struct - -VIDPIDs = [(0x0c45L,0x7401L)] -REQ_INT_LEN = 8 -REQ_BULK_LEN = 8 -TIMEOUT = 2000 - -class TemperDevice(): - def __init__(self, device): - self._device = device - self._handle = None - - def get_temperature(self, format='celsius'): - try: - if not self._handle: - self._handle = self._device.open() - try: - self._handle.detachKernelDriver(0) - except usb.USBError: - pass - try: - self._handle.detachKernelDriver(1) - except usb.USBError: - pass - self._handle.setConfiguration(1) - self._handle.claimInterface(0) - self._handle.claimInterface(1) - self._handle.controlMsg(requestType=0x21, request=0x09, value=0x0201, index=0x00, buffer="\x01\x01", timeout=TIMEOUT) # ini_control_transfer - - self._control_transfer(self._handle, "\x01\x80\x33\x01\x00\x00\x00\x00") # uTemperatura - self._interrupt_read(self._handle) - self._control_transfer(self._handle, "\x01\x82\x77\x01\x00\x00\x00\x00") # uIni1 - self._interrupt_read(self._handle) - self._control_transfer(self._handle, "\x01\x86\xff\x01\x00\x00\x00\x00") # uIni2 - self._interrupt_read(self._handle) - self._interrupt_read(self._handle) - self._control_transfer(self._handle, "\x01\x80\x33\x01\x00\x00\x00\x00") # uTemperatura - data = self._interrupt_read(self._handle) - data_s = "".join([chr(byte) for byte in data]) - temp_c = 125.0/32000.0*(struct.unpack('>h', data_s[2:4])[0]) - if format == 'celsius': - return temp_c - elif format == 'fahrenheit': - return temp_c*1.8+32.0 - elif format == 'millicelsius': - return int(temp_c*1000) - else: - raise ValueError("Unknown format") - except usb.USBError, e: - self.close() - if "not permitted" in str(e): - raise Exception("Permission problem accessing USB. Maybe I need to run as root?") - else: - raise - - def close(self): - if self._handle: - try: - self._handle.releaseInterface() - except ValueError: - pass - self._handle = None - - def _control_transfer(self, handle, data): - handle.controlMsg(requestType=0x21, request=0x09, value=0x0200, index=0x01, buffer=data, timeout=TIMEOUT) - - def _interrupt_read(self, handle): - return handle.interruptRead(0x82, REQ_INT_LEN) - - -class TemperHandler(): - def __init__(self): - busses = usb.busses() - self._devices = [] - for bus in busses: - self._devices.extend([TemperDevice(x) for x in bus.devices if (x.idVendor,x.idProduct) in VIDPIDs]) - - def get_devices(self): - return self._devices - -if __name__ == '__main__': - th = TemperHandler() - devs = th.get_devices() - print "Found %i devices" % len(devs) - for i, dev in enumerate(devs): - print "Device #%i: %0.1f°C %0.1f°F" % (i, dev.get_temperature(), dev.get_temperature(format="fahrenheit")) diff --git a/temperusb/__init__.py b/temperusb/__init__.py new file mode 100644 index 0000000..ef19ec6 --- /dev/null +++ b/temperusb/__init__.py @@ -0,0 +1 @@ +from .temper import TemperDevice, TemperHandler diff --git a/temperusb/cli.py b/temperusb/cli.py new file mode 100644 index 0000000..daaa866 --- /dev/null +++ b/temperusb/cli.py @@ -0,0 +1,110 @@ +# encoding: utf-8 +from __future__ import print_function, absolute_import +import argparse +import logging + +from .temper import TemperHandler + + +def parse_args(): + 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', + help="Display ports") + units = parser.add_mutually_exclusive_group(required=False) + units.add_argument("-c", "--celsius", action='store_true', + help="Quiet: just degrees celcius as decimal") + units.add_argument("-f", "--fahrenheit", action='store_true', + help="Quiet: just degrees fahrenheit as decimal") + units.add_argument("-H", "--humidity", action='store_true', + help="Quiet: just percentage relative humidity as decimal") + parser.add_argument("-s", "--sensor_ids", choices=['0', '1', 'all'], + help="IDs of sensors to use on the device " + + "(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 + + +def main(): + args = parse_args() + quiet = args.celsius or args.fahrenheit or args.humidity + lvl = logging.ERROR if quiet else logging.WARNING + if args.verbose: + lvl = logging.DEBUG + logging.basicConfig(level = lvl) + + th = TemperHandler() + devs = th.get_devices() + if not quiet: + print("Found %i devices" % len(devs)) + + readings = [] + + for dev in devs: + if args.sensor_count is not None: + # Override auto-detection from args + dev.set_sensor_count(int(args.sensor_count)) + + if args.sensor_ids == 'all': + sensors = range(dev.get_sensor_count()) + else: + sensors = [int(args.sensor_ids)] + + temperatures = dev.get_temperatures(sensors=sensors) + humidities = dev.get_humidity(sensors=sensors) + combinations = {} + for k, v in temperatures.items(): + c = v.copy() + try: + c.update(humidities[k]) + except: + pass + combinations[k] = c + readings.append(combinations) + + for i, reading in enumerate(readings): + output = '' + if quiet: + if args.celsius: + dict_key = 'temperature_c' + elif args.fahrenheit: + dict_key = 'temperature_f' + elif args.humidity: + dict_key = 'humidity_pc' + + for sensor in sorted(reading): + output += '%0.1f; ' % reading[sensor][dict_key] + output = output[0:len(output) - 2] + else: + portinfo = '' + tempinfo = '' + huminfo = '' + for sensor in sorted(reading): + if args.disp_ports and portinfo == '': + portinfo = " (bus %(bus)s - port %(ports)s)" % reading[sensor] + try: + tempinfo += '%0.1f°C %0.1f°F; ' % ( + reading[sensor]['temperature_c'], + reading[sensor]['temperature_f'], + ) + except: + pass + try: + huminfo += '%0.1f%%RH; ' % (reading[sensor]['humidity_pc']) + except: + pass + tempinfo = tempinfo[0:len(output) - 2] + huminfo = huminfo[0:len(output) - 2] + + output = 'Device #%i%s: %s %s' % (i, portinfo, tempinfo, huminfo) + print(output) + + +if __name__ == '__main__': + main() 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/temper/snmp_temper.py b/temperusb/snmp.py old mode 100755 new mode 100644 similarity index 72% rename from temper/snmp_temper.py rename to temperusb/snmp.py index 32fc67f..a59ab03 --- a/temper/snmp_temper.py +++ b/temperusb/snmp.py @@ -1,21 +1,26 @@ -#!/usr/bin/python -u # encoding: utf-8 # # Run snmp_temper.py as a pass-persist module for NetSNMP. # See README.md for instructions. # -# Copyright 2012, 2013 Philipp Adelt +# Copyright 2012-2020 Philipp Adelt # # This code is licensed under the GNU public license (GPL). See LICENSE.md for details. +import os import sys import syslog import threading import snmp_passpersist as snmp -from temper import TemperHandler, TemperDevice +from temperusb.temper import TemperHandler, TemperDevice ERROR_TEMPERATURE = 9999 + +def _unbuffered_handle(fd): + return os.fdopen(fd.fileno(), 'w', 0) + + class LogWriter(): def __init__(self, ident='temper-python', facility=syslog.LOG_DAEMON): syslog.openlog(ident, 0, facility) @@ -38,7 +43,7 @@ def _initialize(self): self.logger.write_log('Found %i thermometer devices.' % len(self.devs)) for i, d in enumerate(self.devs): self.logger.write_log('Initial temperature of device #%i: %0.1f degree celsius' % (i, d.get_temperature())) - except Exception, e: + except Exception as e: self.logger.write_log('Exception while initializing: %s' % str(e)) def _reinitialize(self): @@ -48,35 +53,42 @@ def _reinitialize(self): for i,d in enumerate(self.devs): try: d.close() - except Exception, e: + except Exception as e: self.logger.write_log('Exception closing device #%i: %s' % (i, str(e))) self._initialize() def update(self): if self.testmode: # APC Internal/Battery Temperature - pp.add_int('318.1.1.1.2.2.2.0', 99) + self.pp.add_int('318.1.1.1.2.2.2.0', 99) # Cisco devices temperature OIDs - pp.add_int('9.9.13.1.3.1.3.1', 97) - pp.add_int('9.9.13.1.3.1.3.2', 98) - pp.add_int('9.9.13.1.3.1.3.3', 99) + self.pp.add_int('9.9.13.1.3.1.3.1', 97) + self.pp.add_int('9.9.13.1.3.1.3.2', 98) + self.pp.add_int('9.9.13.1.3.1.3.3', 99) else: try: with self.usb_lock: - pp.add_int('318.1.1.1.2.2.2.0', int(max([d.get_temperature() for d in self.devs]))) - for i, dev in enumerate(self.devs[:3]): # use max. first 3 devices - pp.add_int('9.9.13.1.3.1.3.%i' % (i+1), int(dev.get_temperature())) - except Exception, e: + temperatures = [d.get_temperature() for d in self.devs] + self.pp.add_int('318.1.1.1.2.2.2.0', int(max(temperatures))) + for i, temperature in enumerate(temperatures[:3]): # use max. first 3 devices + self.pp.add_int('9.9.13.1.3.1.3.%i' % (i+1), int(temperature)) + except Exception as e: self.logger.write_log('Exception while updating data: %s' % str(e)) # Report an exceptionally large temperature to set off all alarms. # snmp_passpersist does not expose an API to remove an OID. for oid in ('318.1.1.1.2.2.2.0', '9.9.13.1.3.1.3.1', '9.9.13.1.3.1.3.2', '9.9.13.1.3.1.3.3'): - pp.add_int(oid, ERROR_TEMPERATURE) + self.pp.add_int(oid, ERROR_TEMPERATURE) self.logger.write_log('Starting reinitialize after error on update') self._reinitialize() -if __name__ == '__main__': + +def main(): + sys.stdout = _unbuffered_handle(sys.stdout) pp = snmp.PassPersist(".1.3.6.1.4.1") logger = LogWriter() upd = Updater(pp, logger, testmode=('--testmode' in sys.argv)) pp.start(upd.update, 5) # update every 5s + + +if __name__ == '__main__': + main() diff --git a/temperusb/temper.conf.sample b/temperusb/temper.conf.sample new file mode 100644 index 0000000..78221f6 --- /dev/null +++ b/temperusb/temper.conf.sample @@ -0,0 +1,15 @@ +# configuration for calibrating your TEMPer device. +# example line: +#1-1.2: scale = 0.95, offset = -2.2 +# the above example indicates the output y of temper-poll and SNMP for +# your TEMPer device which is on USB bus 1 through port 1 and port 2 will be +# calculated with y = 0.95 * x - 2.2, where x is the raw uncalibrated output +# from the device (in Celsius). +# Default values are scale = 1.0 and offset = 0.0, which simply means y = x. +# Using this bus-(chain of)port(s) combination, you can uniquely specify your +# device. Also you can have lines for multiple TEMPer device since they are +# distinguishable with this scheme. +# Don't just uncomment the example line, you need the specific correct values +# for yourself. +# To decide what these numbers will be in your case, see calibration +# parameters section in README.md. diff --git a/temperusb/temper.py b/temperusb/temper.py new file mode 100644 index 0000000..d8c77a6 --- /dev/null +++ b/temperusb/temper.py @@ -0,0 +1,446 @@ +# 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 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) diff --git a/udev/99-tempsensor.rules b/udev/99-tempsensor.rules deleted file mode 100644 index 30a2e1e..0000000 --- a/udev/99-tempsensor.rules +++ /dev/null @@ -1 +0,0 @@ -SUBSYSTEMS=="usb", ACTION=="add", ATTRS{idVendor}=="0c45", ATTRS{idProduct}=="7401", MODE="666"