diff --git a/.github/workflows/deploy_to_pypi.yml b/.github/workflows/deploy_to_pypi.yml new file mode 100644 index 0000000..ea34c44 --- /dev/null +++ b/.github/workflows/deploy_to_pypi.yml @@ -0,0 +1,45 @@ +name: Deploy to PyPI + +on: + push: + tags: + - 'v*.*.*' # This will trigger the workflow only when a tag that matches the pattern is pushed + +permissions: + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Check Tag and setup.py Version Match + run: | + TAG_VERSION=${GITHUB_REF#refs/tags/v} + SETUP_VERSION=$(grep -oE "version='([^']+)" setup.py | grep -oE "[^'=]+$") + if [[ "$TAG_VERSION" != "$SETUP_VERSION" ]]; then + echo "Tag version $TAG_VERSION does not match setup.py version $SETUP_VERSION." + exit 1 + fi + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel + + - name: Build package + run: | + python setup.py sdist bdist_wheel + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ./dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dfa1c59 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test + +on: ["pull_request", "push"] + +jobs: + Test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -e . + + - name: Run tests + run: pytest -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..482629c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Byte-compiled / optimized files +__pycache__/ +*.py[cod] + +# Test / tooling caches +.pytest_cache/ + +# Packaging artifacts +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b421e1 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# tmp119-python + +A Python module to interface with the TMP119 temperature sensor. The TMP119 is +an ultra-high-accuracy, low-power digital temperature sensor from Texas +Instruments with an I2C-compatible interface. Tested on Raspberry Pi with +Raspberry Pi OS. + +The python SMBus library must be installed. + + sudo apt-get install python3-smbus + +# Usage + + import tmp119 + +### TMP119(bus=1, address=0x48) + + sensor = tmp119.TMP119() # Use default I2C bus 1, address 0x48 + sensor = tmp119.TMP119(0) # Specify I2C bus 0 + sensor = tmp119.TMP119(1, 0x49) # Specify bus and I2C address + +The TMP119 supports up to four I2C addresses, selected by the ADD0 pin: + + 0x48 (ADD0 to GND, default) + 0x49 (ADD0 to V+) + 0x4A (ADD0 to SDA) + 0x4B (ADD0 to SCL) + +### init() + +Initialize the sensor. This needs to be called before using any other methods. +It verifies the device ID and configures the sensor for the fastest update rate +(no averaging, no added standby delay, ~15.5 ms cycle). Call `set_averaging()` / +`set_read_delay()` afterwards to trade speed for lower noise. + + sensor.init() + +Returns True if the sensor was successfully initialized, False otherwise. + +### read() + +Read the sensor and update the temperature. The TMP119 runs in +continuous-conversion mode, so this always returns the latest result. + + sensor.read() + +Returns True if the read was successful, False otherwise. + +### temperature(conversion=UNITS_Centigrade) + +Get the most recent temperature measurement. + + sensor.temperature() # Centigrade (default) + sensor.temperature(tmp119.UNITS_Fahrenheit) # Fahrenheit + +Valid arguments are: + + tmp119.UNITS_Centigrade + tmp119.UNITS_Fahrenheit + tmp119.UNITS_Kelvin + +Returns the most recent temperature in the requested units, or temperature in +degrees Centigrade if invalid units specified. Call `read()` to update. + +### set_averaging(avg) + +Set the conversion averaging mode. More averaging reduces noise but takes +longer to produce each result. Returns True if the write succeeded. + + sensor.set_averaging(tmp119.TMP119_AVERAGE_64X) + +Valid arguments are (with the time each takes per result): + + tmp119.TMP119_AVERAGE_1X # 15.5 ms + tmp119.TMP119_AVERAGE_8X # 125 ms + tmp119.TMP119_AVERAGE_32X # 500 ms + tmp119.TMP119_AVERAGE_64X # 1 s + +### set_read_delay(delay) + +Set the *minimum* standby delay between conversions in continuous-conversion +mode. The actual time between readings is the greater of this standby delay and +the averaging time (see datasheet Table 8-6); for example, `TMP119_AVERAGE_64X` +always yields at least a ~1 s cycle because the averaging alone takes 1 s, +regardless of the delay setting. Returns True if the write succeeded. + + sensor.set_read_delay(tmp119.TMP119_DELAY_1000_MS) + +Valid arguments are: + + tmp119.TMP119_DELAY_NONE + tmp119.TMP119_DELAY_125_MS + tmp119.TMP119_DELAY_250_MS + tmp119.TMP119_DELAY_500_MS + tmp119.TMP119_DELAY_1000_MS + tmp119.TMP119_DELAY_4000_MS + tmp119.TMP119_DELAY_8000_MS + tmp119.TMP119_DELAY_16000_MS + +### get_config() / set_config(config) + +Read or write the raw 16-bit configuration register (address 0x01) for advanced +use. `set_config()` returns True if the write succeeded; read-only bits are +ignored by the device. + + config = sensor.get_config() + sensor.set_config(config) + +# Reference + +You can find the [TMP119 datasheet here](https://www.ti.com/lit/ds/symlink/tmp119.pdf). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..26acb4e --- /dev/null +++ b/conftest.py @@ -0,0 +1,47 @@ +"""Shared pytest fixtures. + +The tests run without hardware by substituting a fake ``smbus`` module for the +real one. The fake is injected into ``sys.modules`` here, before ``tmp119`` is +imported anywhere, so the library's top-level ``import smbus`` resolves to this +in-memory stand-in instead of a physical I2C bus. +""" + +import sys +import types + +import pytest + + +class FakeSMBus: + """In-memory stand-in for ``smbus.SMBus`` used for hardware-free testing. + + Registers are stored big-endian as ``[msb, lsb]``, matching how the TMP119 + transfers its 16-bit registers over I2C. Tests can read/modify ``regs`` to + simulate device state. + """ + + def __init__(self, bus): + self.bus = bus + self.regs = { + 0x00: [0x00, 0x00], # TEMP + 0x01: [0x02, 0x20], # CONFIG (power-on default 0x0220) + 0x0F: [0x21, 0x17], # DEVICE_ID (0x2117) + } + + def read_i2c_block_data(self, address, register, length): + return list(self.regs[register])[:length] + + def write_i2c_block_data(self, address, register, data): + self.regs[register] = list(data) + + +_fake_smbus = types.ModuleType("smbus") +_fake_smbus.SMBus = FakeSMBus +sys.modules["smbus"] = _fake_smbus + + +@pytest.fixture +def sensor(): + """A TMP119 backed by a fresh in-memory FakeSMBus.""" + import tmp119 + return tmp119.TMP119() diff --git a/example.py b/example.py new file mode 100644 index 0000000..113f91f --- /dev/null +++ b/example.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import tmp119 +from time import sleep + +# Optional constructor parameters: TMP119(bus, address) +# bus - I2C bus number (default 1; busses are listed as /dev/i2c*) +# address - I2C address set by the ADD0 pin (default 0x48): +# 0x48 (GND), 0x49 (V+), 0x4A (SDA), 0x4B (SCL) +sensor = tmp119.TMP119() + +if not sensor.init(): + print("Error initializing sensor") + exit(1) + +# init() configures the fastest update rate. Override it here with low-noise +# settings: average 64 conversions and update roughly once per second. +# +# Averaging options (set_averaging) and the time each takes per result: +# TMP119_AVERAGE_1X -> 15.5 ms +# TMP119_AVERAGE_8X -> 125 ms +# TMP119_AVERAGE_32X -> 500 ms +# TMP119_AVERAGE_64X -> 1 s +# +# Minimum standby delay options (set_read_delay): +# TMP119_DELAY_NONE, TMP119_DELAY_125_MS, TMP119_DELAY_250_MS, +# TMP119_DELAY_500_MS, TMP119_DELAY_1000_MS, TMP119_DELAY_4000_MS, +# TMP119_DELAY_8000_MS, TMP119_DELAY_16000_MS +# +# The actual time between readings is the greater of the averaging time above +# and the standby delay (datasheet Table 8-6). So 64x averaging with +# TMP119_DELAY_1000_MS gives ~1 s, but 64x with TMP119_DELAY_NONE is also ~1 s +# since the averaging itself takes 1 s. +sensor.set_averaging(tmp119.TMP119_AVERAGE_64X) +sensor.set_read_delay(tmp119.TMP119_DELAY_1000_MS) + +while True: + if not sensor.read(): + print("Error reading sensor") + exit(1) + print("Temperature: %.2f C\t%.2f F" % ( + sensor.temperature(), + sensor.temperature(tmp119.UNITS_Fahrenheit))) + sleep(1) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6a0e02b --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +from setuptools import setup, find_packages + +with open("README.md", "r") as f: + long_description = f.read() + +setup(name='bluerobotics-tmp119', + version='0.0.1', + description='A python module for the TMP119 digital temperature sensor', + long_description=long_description, + long_description_content_type='text/markdown', + author='Blue Robotics', + author_email='support@bluerobotics.com', + url='https://www.bluerobotics.com', + packages=find_packages(), + python_requires='>=3.6', + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ] + ) diff --git a/tests/test_tmp119.py b/tests/test_tmp119.py new file mode 100644 index 0000000..3133e4f --- /dev/null +++ b/tests/test_tmp119.py @@ -0,0 +1,80 @@ +import pytest + +import tmp119 + +# Config register bit masks (mirrors the private constants in the driver) +AVG_MASK = 0x0060 +CONV_MASK = 0x0380 + + +def test_device_id(sensor): + assert sensor.get_device_id() == 0x2117 + + +def test_init_success_clears_avg_and_conv(sensor): + assert sensor.init() is True + assert sensor.get_config() & (AVG_MASK | CONV_MASK) == 0 + + +def test_init_preserves_unrelated_config_bits(sensor): + # Set a bit outside the AVG/CONV masks; init() must leave it untouched. + sensor._bus.regs[0x01] = [0x82, 0x20] # 0x8220 + assert sensor.init() is True + assert sensor.get_config() == 0x8000 + + +def test_init_fails_on_wrong_device_id(sensor): + sensor._bus.regs[0x0F] = [0x00, 0x00] + assert sensor.init() is False + + +def test_init_returns_false_without_bus(sensor): + sensor._bus = None + assert sensor.init() is False + + +def test_read_returns_false_without_bus(sensor): + sensor._bus = None + assert sensor.read() is False + + +def test_read_positive_temperature(sensor): + sensor._bus.regs[0x00] = [0x0C, 0x80] # 3200 * 0.0078125 = 25.0 C + assert sensor.read() is True + assert sensor.temperature() == pytest.approx(25.0) + + +def test_read_negative_temperature(sensor): + sensor._bus.regs[0x00] = [0xFF, 0x00] # -256 * 0.0078125 = -2.0 C + sensor.read() + assert sensor.temperature() == pytest.approx(-2.0) + + +def test_temperature_unit_conversions(sensor): + sensor._bus.regs[0x00] = [0x0C, 0x80] # 25.0 C + sensor.read() + assert sensor.temperature(tmp119.UNITS_Centigrade) == pytest.approx(25.0) + assert sensor.temperature(tmp119.UNITS_Fahrenheit) == pytest.approx(77.0) + assert sensor.temperature(tmp119.UNITS_Kelvin) == pytest.approx(298.15) + + +def test_set_averaging(sensor): + assert sensor.set_averaging(tmp119.TMP119_AVERAGE_64X) is True + assert sensor.get_config() & AVG_MASK == (3 << 5) + + +def test_set_read_delay(sensor): + assert sensor.set_read_delay(tmp119.TMP119_DELAY_16000_MS) is True + assert sensor.get_config() & CONV_MASK == (7 << 7) + + +def test_set_averaging_preserves_read_delay(sensor): + sensor.set_read_delay(tmp119.TMP119_DELAY_8000_MS) + sensor.set_averaging(tmp119.TMP119_AVERAGE_32X) + assert sensor.get_config() & CONV_MASK == (6 << 7) + assert sensor.get_config() & AVG_MASK == (2 << 5) + + +def test_set_config_round_trips_16_bits(sensor): + sensor.set_config(0xABCD) + assert sensor.get_config() == 0xABCD diff --git a/tmp119/__init__.py b/tmp119/__init__.py new file mode 100644 index 0000000..4731daf --- /dev/null +++ b/tmp119/__init__.py @@ -0,0 +1 @@ +from .tmp119 import * diff --git a/tmp119/tmp119.py b/tmp119/tmp119.py new file mode 100644 index 0000000..24b740d --- /dev/null +++ b/tmp119/tmp119.py @@ -0,0 +1,169 @@ +"""Driver for the Texas Instruments TMP119 high-accuracy temperature sensor.""" + +try: + import smbus +except ImportError: + print('Try sudo apt-get install python3-smbus') + +# Valid units +UNITS_Centigrade = 1 +UNITS_Fahrenheit = 2 +UNITS_Kelvin = 3 + +# Conversion averaging mode (AVG[1:0], config register bits 6:5). +# More averaging reduces noise but takes longer to produce each result. The +# time to compute one averaged result is the per-mode minimum conversion time +# shown below (datasheet Table 8-6). Power-on default: TMP119_AVERAGE_8X. +TMP119_AVERAGE_1X = 0 # No averaging (min conversion time 15.5 ms) +TMP119_AVERAGE_8X = 1 # 8 conversions (min conversion time 125 ms, default) +TMP119_AVERAGE_32X = 2 # 32 conversions (min conversion time 500 ms) +TMP119_AVERAGE_64X = 3 # 64 conversions (min conversion time 1 s) + +# Minimum standby delay inserted between conversions in continuous-conversion +# mode (CONV[2:0], config register bits 9:7). +# +# This is not the actual time between readings on its own. The actual +# conversion cycle time is the greater of this standby delay and the time +# needed by the selected averaging mode (see the averaging constants above). +# For example, TMP119_DELAY_NONE with TMP119_AVERAGE_64X still produces a ~1 s +# cycle because the 64x average alone takes 1 s. See datasheet Table 8-6. +# +# Power-on default: TMP119_DELAY_1000_MS. +TMP119_DELAY_NONE = 0 # no added standby delay +TMP119_DELAY_125_MS = 1 +TMP119_DELAY_250_MS = 2 +TMP119_DELAY_500_MS = 3 +TMP119_DELAY_1000_MS = 4 # power-on default +TMP119_DELAY_4000_MS = 5 +TMP119_DELAY_8000_MS = 6 +TMP119_DELAY_16000_MS = 7 + + +class TMP119: + + # Registers + _TEMP_REG = 0x00 + _CONFIG_REG = 0x01 + _DEVICE_ID_REG = 0x0F + _DEVICE_ID = 0x2117 + + # CONV[2:0] (standby delay), config register bits 9:7 + _CONV_SHIFT = 7 + _CONV_MASK = 0x0380 + # AVG[1:0] (averaging), config register bits 6:5 + _AVG_SHIFT = 5 + _AVG_MASK = 0x0060 + + # Each LSB of the temperature register represents this many degrees C + _LSB_C = 0.0078125 + + def __init__(self, bus=1, address=0x48): + """Create a sensor on the given I2C bus and address. + + The TMP119 supports up to four I2C addresses (0x48 - 0x4B), selected by + the ADD0 pin. The default address (ADD0 to GND) is 0x48. + """ + self._address = address + + # Degrees C + self._temperature = 0 + + try: + self._bus = smbus.SMBus(bus) + except Exception: + print("Bus %d is not available." % bus) + print("Available busses are listed as /dev/i2c*") + self._bus = None + + def init(self): + """Verify the device and configure it for the fastest update rate. + + Returns True on success, False if the bus is unavailable or the device + ID does not match. Call set_averaging()/set_read_delay() afterwards to + trade speed for lower noise. + """ + if self._bus is None: + print("No bus!") + return False + + if self.get_device_id() != self._DEVICE_ID: + return False + + # Configure for the fastest update rate: no averaging and no added + # standby delay, giving a ~15.5 ms conversion cycle (datasheet Table + # 8-6). Clearing the AVG and CONV bits sets AVG = 1X and delay = none. + config = self.get_config() + config &= ~(self._AVG_MASK | self._CONV_MASK) + return self.set_config(config) + + def read(self): + """Read the latest conversion and update the stored temperature. + + Returns True on success, False if the bus is unavailable. + """ + if self._bus is None: + print("No bus!") + return False + + # The TMP119 powers up in continuous-conversion mode, so the + # temperature register always holds the most recent conversion. + raw = self._read_register(self._TEMP_REG) + + # Data is in 2's complement format + if raw > 32767: + raw -= 65536 + + self._temperature = raw * self._LSB_C + return True + + def temperature(self, conversion=UNITS_Centigrade): + """Return the most recent temperature in the requested units. + + Defaults to degrees Centigrade. Call read() to update the value. + """ + if conversion == UNITS_Fahrenheit: + return (9 / 5) * self._temperature + 32 + elif conversion == UNITS_Kelvin: + return self._temperature + 273.15 + return self._temperature + + def set_averaging(self, avg): + """Set the conversion averaging mode, preserving other config bits.""" + config = self.get_config() + config = (config & ~self._AVG_MASK) | \ + ((avg << self._AVG_SHIFT) & self._AVG_MASK) + return self.set_config(config) + + def set_read_delay(self, delay): + """Set the standby delay between conversions, preserving other bits.""" + config = self.get_config() + config = (config & ~self._CONV_MASK) | \ + ((delay << self._CONV_SHIFT) & self._CONV_MASK) + return self.set_config(config) + + def get_config(self): + """Read the raw 16-bit configuration register (address 0x01).""" + return self._read_register(self._CONFIG_REG) + + def set_config(self, config): + """Write the raw 16-bit configuration register (address 0x01). + + Read-only bits are ignored by the device. + """ + return self._write_register(self._CONFIG_REG, config) + + def get_device_id(self): + """Read the raw 16-bit device ID register (address 0x0F).""" + return self._read_register(self._DEVICE_ID_REG) + + # The TMP119 transfers 16-bit registers MSB first. smbus word transfers are + # little-endian, so we use block transfers and order the bytes ourselves. + def _read_register(self, register): + data = self._bus.read_i2c_block_data(self._address, register, 2) + return (data[0] << 8) | data[1] + + def _write_register(self, register, value): + value &= 0xFFFF + self._bus.write_i2c_block_data( + self._address, register, [(value >> 8) & 0xFF, value & 0xFF]) + return True