diff --git a/pslab/external/MLX90614.py b/pslab/external/MLX90614.py index 6b96367..ec16e81 100644 --- a/pslab/external/MLX90614.py +++ b/pslab/external/MLX90614.py @@ -1,64 +1,136 @@ -from pslab.bus import I2CSlave +"""MLX90614 infrared thermometer. + +This module provides an interface for the MLX90614 non-contact infrared +temperature sensor, connected to the PSLab via I2C. + +Examples +-------- +Read object (target) temperature: + +>>> from pslab.external.MLX90614 import MLX90614 +>>> sensor = MLX90614() +>>> sensor.get_object_temperature() +25.73 + +Read ambient (sensor body) temperature: + +>>> sensor.get_ambient_temperature() +24.18 +""" + +import logging +from typing import List, Optional + +from pslab.bus.i2c import I2CSlave +from pslab.connection import ConnectionHandler + +logger = logging.getLogger(__name__) + class MLX90614(I2CSlave): + """MLX90614 non-contact infrared temperature sensor. + + The MLX90614 is a passive infrared (PIR) sensor that measures + temperature without physical contact. It can measure both the + temperature of a target object and its own ambient temperature. + + The sensor communicates over SMBus (a subset of I2C) at address 0x5A + and supports bus speeds up to 100 kHz. + + Parameters + ---------- + device : :class:`ConnectionHandler`, optional + Serial connection to PSLab device. If not provided, a new one + will be created. + + Attributes + ---------- + NUMPLOTS : int + Number of data plots for GUI integration. + PLOTNAMES : list of str + Labels for data plots. + name : str + Human-readable sensor name. + """ + _ADDRESS = 0x5A - _OBJADDR = 0x07 - _AMBADDR = 0x06 + _OBJ_REGISTER = 0x07 + _AMB_REGISTER = 0x06 NUMPLOTS = 1 - PLOTNAMES = ['Temp'] - name = 'PIR temperature' - - def __init__(self): - super().__init__(self._ADDRESS) - - self.source = self._OBJADDR - - self.name = 'Passive IR temperature sensor' - self.params = {'readReg': {'dataType': 'integer', 'min': 0, 'max': 0x20, 'prefix': 'Addr: '}, - 'select_source': ['object temperature', 'ambient temperature']} - - # try: - # print('switching baud to 100k') - # self.I2C.configI2C(100e3) - # except Exception as e: - # print('FAILED TO CHANGE BAUD RATE', e.message) - - def select_source(self, source): - if source == 'object temperature': - self.source = self._OBJADDR - elif source == 'ambient temperature': - self.source = self._AMBADDR - - def readReg(self, addr): - x = self.getVals(addr, 2) - print(hex(addr), hex(x[0] | (x[1] << 8))) - - def getVals(self, addr, numbytes): - vals = self.read(numbytes, addr) - return vals - - def getRaw(self): - vals = self.getVals(self.source, 3) - if vals: - if len(vals) == 3: - return [((((vals[1] & 0x007f) << 8) + vals[0]) * 0.02) - 0.01 - 273.15] - else: - return False - else: - return False - - def getObjectTemperature(self): - self.source = self._OBJADDR - val = self.getRaw() - if val: - return val[0] - else: - return False - - def getAmbientTemperature(self): - self.source = self._AMBADDR - val = self.getRaw() - if val: - return val[0] - else: - return False + PLOTNAMES = ["Temp"] + name = "PIR temperature" + + def __init__(self, device: Optional[ConnectionHandler] = None): + super().__init__(self._ADDRESS, device=device) + self._source = self._OBJ_REGISTER + self.name = "Passive IR temperature sensor" + + def select_source(self, source: str): + """Select which temperature source to read. + + Parameters + ---------- + source : str + Either ``'object temperature'`` or ``'ambient temperature'``. + """ + if source == "object temperature": + self._source = self._OBJ_REGISTER + elif source == "ambient temperature": + self._source = self._AMB_REGISTER + + def read_reg(self, register: int): + """Read and log a 16-bit register value. + + Parameters + ---------- + register : int + Register address to read (0x00–0x20). + """ + data = self.read(2, register) + value = data[0] | (data[1] << 8) + logger.info("Register %s: %s", hex(register), hex(value)) + + def get_raw(self) -> Optional[List[float]]: + """Read raw temperature from the currently selected source. + + The raw value is read as a 3-byte SMBus word (LSB, MSB, PEC) + and converted from the sensor's internal unit (0.02 K per LSB) + to degrees Celsius. + + Returns + ------- + list of float or None + Single-element list with temperature in °C, or None if + the read failed. + """ + data = self.read(3, self._source) + + if data and len(data) == 3: + raw = (((data[1] & 0x007F) << 8) + data[0]) * 0.02 - 0.01 + return [raw - 273.15] + + return None + + def get_object_temperature(self) -> Optional[float]: + """Read the temperature of the target object. + + Returns + ------- + float or None + Object temperature in °C, or None if the read failed. + """ + self._source = self._OBJ_REGISTER + result = self.get_raw() + return result[0] if result else None + + def get_ambient_temperature(self) -> Optional[float]: + """Read the ambient (sensor body) temperature. + + Returns + ------- + float or None + Ambient temperature in °C, or None if the read failed. + """ + self._source = self._AMB_REGISTER + result = self.get_raw() + return result[0] if result else None diff --git a/tests/test_mlx90614.py b/tests/test_mlx90614.py new file mode 100644 index 0000000..16ff4e5 --- /dev/null +++ b/tests/test_mlx90614.py @@ -0,0 +1,188 @@ +"""Tests for MLX90614 infrared thermometer (Issue #182). + +These are mock-based unit tests that run without a physical PSLab device. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from pslab.external.MLX90614 import MLX90614 + + +@pytest.fixture +def mock_device(): + """Return a mock ConnectionHandler.""" + device = MagicMock() + device.send_byte = MagicMock() + device.send_int = MagicMock() + device.get_ack = MagicMock(return_value=1) + return device + + +@pytest.fixture +def sensor(mock_device): + """Return an MLX90614 with a mocked device.""" + with patch("pslab.bus.i2c.autoconnect", return_value=mock_device): + return MLX90614(device=mock_device) + + +# ============================================================ +# Test 1: Initialization +# ============================================================ +class TestInitialization: + + def test_default_address(self, sensor): + """Sensor should use address 0x5A.""" + assert sensor.address == 0x5A + + def test_default_source_is_object(self, sensor): + """Default measurement source should be the object register.""" + assert sensor._source == 0x07 + + def test_name_is_set(self, sensor): + """Sensor should have a descriptive name.""" + assert sensor.name == "Passive IR temperature sensor" + + def test_accepts_device_parameter(self, mock_device): + """Sensor should accept an optional device parameter.""" + with patch("pslab.bus.i2c.autoconnect", return_value=mock_device): + s = MLX90614(device=mock_device) + assert s.address == 0x5A + + +# ============================================================ +# Test 2: Source selection +# ============================================================ +class TestSourceSelection: + + def test_select_object_temperature(self, sensor): + """Selecting 'object temperature' sets source to OBJ register.""" + sensor.select_source("object temperature") + assert sensor._source == 0x07 + + def test_select_ambient_temperature(self, sensor): + """Selecting 'ambient temperature' sets source to AMB register.""" + sensor.select_source("ambient temperature") + assert sensor._source == 0x06 + + def test_invalid_source_no_change(self, sensor): + """Selecting an invalid source should not change the current one.""" + original = sensor._source + sensor.select_source("invalid source") + assert sensor._source == original + + +# ============================================================ +# Test 3: Temperature calculation +# ============================================================ +class TestTemperatureCalculation: + + def test_known_temperature_conversion(self, sensor, mock_device): + """Verify temperature conversion math with known raw bytes. + + Raw value 0x3A98 (15000) at 0.02 K per LSB = 300.00 K - 0.01 + = 299.99 K = 26.84 °C. + """ + # 15000 = 0x3A98: LSB = 0x98, MSB = 0x3A, PEC = 0x00 + mock_device.read = MagicMock(return_value=b"\x98\x3A\x00") + + with patch.object(sensor, "read", return_value=bytearray(b"\x98\x3A\x00")): + result = sensor.get_raw() + + assert result is not None + assert len(result) == 1 + expected = ((((0x3A & 0x7F) << 8) + 0x98) * 0.02 - 0.01) - 273.15 + assert result[0] == pytest.approx(expected) + + def test_get_raw_returns_none_on_empty_read(self, sensor): + """get_raw should return None when read returns empty data.""" + with patch.object(sensor, "read", return_value=bytearray()): + result = sensor.get_raw() + assert result is None + + def test_get_raw_returns_none_on_short_read(self, sensor): + """get_raw should return None when fewer than 3 bytes are read.""" + with patch.object(sensor, "read", return_value=bytearray(b"\x00\x01")): + result = sensor.get_raw() + assert result is None + + +# ============================================================ +# Test 4: High-level temperature methods +# ============================================================ +class TestTemperatureMethods: + + def test_get_object_temperature_sets_source(self, sensor): + """get_object_temperature should set source to OBJ register.""" + with patch.object(sensor, "read", return_value=bytearray(b"\x98\x3A\x00")): + sensor.get_object_temperature() + assert sensor._source == 0x07 + + def test_get_ambient_temperature_sets_source(self, sensor): + """get_ambient_temperature should set source to AMB register.""" + with patch.object(sensor, "read", return_value=bytearray(b"\x98\x3A\x00")): + sensor.get_ambient_temperature() + assert sensor._source == 0x06 + + def test_get_object_temperature_returns_float(self, sensor): + """get_object_temperature should return a single float.""" + with patch.object(sensor, "read", return_value=bytearray(b"\x98\x3A\x00")): + result = sensor.get_object_temperature() + assert isinstance(result, float) + + def test_get_ambient_temperature_returns_none_on_failure(self, sensor): + """Should return None when the read fails.""" + with patch.object(sensor, "read", return_value=bytearray()): + result = sensor.get_ambient_temperature() + assert result is None + + +# ============================================================ +# Test 5: read_reg uses logging +# ============================================================ +class TestReadReg: + + def test_read_reg_logs_value(self, sensor, caplog): + """read_reg should log the register value, not print it.""" + with patch.object(sensor, "read", return_value=bytearray(b"\x34\x12")): + import logging + with caplog.at_level(logging.INFO): + sensor.read_reg(0x07) + + assert "0x1234" in caplog.text + + +# ============================================================ +# Test 6: Return types +# ============================================================ +class TestReturnTypes: + + def test_get_raw_returns_list_of_float(self, sensor): + """get_raw should return a list containing one float.""" + with patch.object(sensor, "read", return_value=bytearray(b"\x98\x3A\x00")): + result = sensor.get_raw() + assert isinstance(result, list) + assert isinstance(result[0], float) + + def test_temperature_is_reasonable(self, sensor): + """A known raw value should produce a reasonable temperature.""" + # Room temp ~25°C: raw ~14915 (0x3A43) + with patch.object(sensor, "read", return_value=bytearray(b"\x43\x3A\x00")): + temp = sensor.get_object_temperature() + assert -40 < temp < 85 # Sensor operating range + + +# ============================================================ +# Test 7: Class attributes +# ============================================================ +class TestClassAttributes: + + def test_numplots(self): + assert MLX90614.NUMPLOTS == 1 + + def test_plotnames(self): + assert MLX90614.PLOTNAMES == ["Temp"] + + def test_class_name(self): + assert MLX90614.name == "PIR temperature"