Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 132 additions & 60 deletions pslab/external/MLX90614.py
Original file line number Diff line number Diff line change
@@ -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
188 changes: 188 additions & 0 deletions tests/test_mlx90614.py
Original file line number Diff line number Diff line change
@@ -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"