From 074368409a14ddfc2e49573bf4a408426a390486 Mon Sep 17 00:00:00 2001 From: ineskhou Date: Fri, 17 Oct 2025 00:32:15 -0700 Subject: [PATCH] added light sensor --- .../light_sensor/manager/veml6031x00.py | 256 ++++++++++++++++++ .../manager/test_veml6031x00_manager.py | 167 ++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 circuitpython-workspaces/flight-software/src/pysquared/hardware/light_sensor/manager/veml6031x00.py create mode 100644 cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/light_sensor/manager/test_veml6031x00_manager.py diff --git a/circuitpython-workspaces/flight-software/src/pysquared/hardware/light_sensor/manager/veml6031x00.py b/circuitpython-workspaces/flight-software/src/pysquared/hardware/light_sensor/manager/veml6031x00.py new file mode 100644 index 00000000..1022d3fb --- /dev/null +++ b/circuitpython-workspaces/flight-software/src/pysquared/hardware/light_sensor/manager/veml6031x00.py @@ -0,0 +1,256 @@ +"""This module defines the `VEML6031X00Manager` class and a minimal CircuitPython-style +driver for the Vishay VEML6031X00 ambient light sensor. + +It exposes a manager compatible with `LightSensorProto` and returns readings as +`Light` and `Lux` objects, mirroring the API of `VEML7700Manager`. + +References: +- Vishay VEML6031X00 Datasheet: https://www.vishay.com/docs/80007/veml6031x00.pdf +""" + +import time + +from busio import I2C + +from ....logger import Logger +from ....protos.light_sensor import LightSensorProto +from ....sensor_reading.error import ( + SensorReadingUnknownError, + SensorReadingValueError, +) +from ....sensor_reading.light import Light +from ....sensor_reading.lux import Lux +from ...exception import HardwareInitializationError + +try: + # CircuitPython does not ship typing, guard import + pass +except ImportError: + pass + + +class _VEML6031X00: + """Minimal CircuitPython-style driver for VEML6031X00. + + Implements the subset needed by our manager: + - configuration (enable/disable) + - read ambient-light data and compute lux + + Notes + ----- + The device supports two 7-bit I2C addresses per ordering code; the default for + VEML6031X00 is 0x29 according to the datasheet (see table "SLAVE ADDRESS OPTIONS"). + """ + + # I2C address (7-bit) for VEML6031X00 variant + _DEFAULT_ADDRESS: int = 0x29 + + # Register map (16-bit registers, little-endian LSB first) + _REG_ALS_CONF: int = 0x00 + _REG_ALS_WH: int = 0x01 + _REG_ALS_WL: int = 0x02 + _REG_POWER_SAVING: int = 0x03 + _REG_ALS: int = 0x04 + _REG_WHITE: int = 0x05 + _REG_ALS_INT: int = 0x06 + + # ALS_CONF bit fields we'll use (see datasheet). We'll default to gain x1 and IT 100ms. + # Bits: [15:0] = {RESERVED, SD, INT_EN, PERS[1:0], IT[2:0], GAIN[1:0]} + _ALS_CONF_SD_BIT: int = ( + 0x0001 # SD at bit0 (datasheet SD at bit0: 0=enable, 1=shutdown) + ) + + # Integration time field (IT[2:0]) positions start at bit 6 in some VEML family devices. + # For VEML6031X00 the mapping per datasheet table: + # 3'b000=100ms, 001=200ms, 010=400ms, 011=800ms, 100=50ms, 101=25ms, 110=12.5ms, 111=6.25ms + _IT_SHIFT: int = 6 + + # Gain field (GAIN[1:0]) positions at bits [11:10]: 00=x1, 01=x2, 10=x0.5, 11=x0.125 (per datasheet table) + _GAIN_SHIFT: int = ( + 11 - 1 + ) # compute below to avoid mypy in CP; we set explicitly in code + + # Cached configuration + _als_conf: int + + def __init__(self, i2c: I2C, address: int | None = None) -> None: + """Initialize the device with default configuration. + + Args: + i2c: Initialized I2C bus. + address: Optional 7-bit I2C address; defaults to 0x29. + """ + self._i2c: I2C = i2c + self._address: int = address if address is not None else self._DEFAULT_ADDRESS + # Defaults: power on (SD=0), gain x1, IT=100ms + it_code: int = 0 # 100ms + gain_code: int = 0 # x1 + self._als_conf = self._encode_als_conf( + sd=False, it_code=it_code, gain_code=gain_code + ) + self._write_u16(self._REG_ALS_CONF, self._als_conf) + + # Public properties matching style used by VEML7700 driver + @property + def light(self) -> float: + """Return non-unit light reading (ambient-light raw counts).""" + als_counts = self._read_u16(self._REG_ALS) + return float(als_counts) + + @property + def lux(self) -> float: + """Return computed lux value based on counts and configuration. + + Lux = counts * resolution(gain, integration_time) + Resolution table is provided in datasheet. We implement a compact + resolver for the default configuration and common settings. + """ + counts = self._read_u16(self._REG_ALS) + resolution = self._resolution_lx_per_count(self._als_conf) + return float(counts) * resolution + + # Internals + def _resolution_lx_per_count(self, als_conf: int) -> float: + """Compute lux-per-count resolution from the current configuration. + + Args: + als_conf: Encoded ALS_CONF register shadow. + + Returns: + Resolution in lux per count. + """ + # Decode integration time and gain + it_code = (als_conf >> self._IT_SHIFT) & 0x7 + # Gain bits per datasheet at [11:10]; compute explicitly + gain_code = (als_conf >> 11) & 0x3 + + # Base resolution for IT=100ms, GAIN=x1 is 0.0272 lx/ct (from datasheet table) + base_resolution = 0.0272 + + # Adjust for integration time scaling (double IT halves resolution per-count) + # Mapping relative to 100ms + it_scale = { + 0: 1.0, # 100ms + 1: 0.5, # 200ms -> 0.0272/2 + 2: 0.25, # 400ms -> 0.0272/4 + 3: 0.125, # 800ms -> 0.0272/8 + 4: 2.0, # 50ms -> 0.0272*2 + 5: 4.0, # 25ms -> 0.0272*4 + 6: 8.0, # 12.5ms + 7: 16.0, # 6.25ms + }.get(it_code, 1.0) + + # Gain scaling relative to x1 + gain_scale = { + 0: 1.0, # x1 + 1: 0.5, # x2 gain halves lx/ct + 2: 2.0, # x0.5 doubles lx/ct + 3: 8.0, # x0.125 multiplies by 8 + }.get(gain_code, 1.0) + + return base_resolution * it_scale * gain_scale + + def _encode_als_conf(self, sd: bool, it_code: int, gain_code: int) -> int: + """Encode shutdown, integration time, and gain into ALS_CONF register value.""" + conf = 0 + # SD bit 0: 0 = enable, 1 = shutdown + if sd: + conf |= self._ALS_CONF_SD_BIT + # IT at bits [8:6] + conf |= (it_code & 0x7) << self._IT_SHIFT + # GAIN at bits [11:10] + conf |= (gain_code & 0x3) << 10 + return conf + + def _write_u16(self, register: int, value: int) -> None: + """Write a 16-bit little-endian value to a register.""" + # VEML devices use little-endian (LSB first) for 16-bit registers + data = bytes((register & 0xFF, value & 0xFF, (value >> 8) & 0xFF)) + while not self._i2c.try_lock(): + pass + try: + self._i2c.writeto(self._address, data) + finally: + self._i2c.unlock() + + def _read_u16(self, register: int) -> int: + """Read a 16-bit little-endian value from a register.""" + # Write register, then read 2 bytes LSB first + reg = bytes((register & 0xFF,)) + buf = bytearray(2) + while not self._i2c.try_lock(): + pass + try: + # Use repeated start via writeto_then_readfrom to satisfy typing and behavior + self._i2c.writeto_then_readfrom(self._address, reg, buf) + finally: + self._i2c.unlock() + return buf[0] | (buf[1] << 8) + + +class VEML6031X00Manager(LightSensorProto): + """Manages the VEML6031X00 ambient light sensor. + + This mirrors the `VEML7700Manager` surface so it can be swapped easily. + """ + + def __init__(self, logger: Logger, i2c: I2C, address: int | None = None) -> None: + """Initialize the manager and underlying driver. + + Args: + logger: Logger for diagnostic messages. + i2c: Initialized I2C bus. + address: Optional 7-bit I2C address; defaults per part number. + """ + self._log: Logger = logger + try: + self._log.debug("Initializing light sensor") + self._light_sensor = _VEML6031X00(i2c, address) + except Exception as e: + raise HardwareInitializationError( + "Failed to initialize light sensor" + ) from e + + def get_light(self) -> Light: + """Return non-unit-specific light level as a `Light` reading.""" + try: + return Light(self._light_sensor.light) + except Exception as e: + raise SensorReadingUnknownError("Failed to get light reading") from e + + def get_lux(self) -> Lux: + """Return lux as a `Lux` reading; validates non-zero, non-None values.""" + try: + # Saturation check: Zephyr treats raw ambient-light 0xFFFF as saturation + raw_counts = self._light_sensor._read_u16(self._light_sensor._REG_ALS) + if raw_counts == 0xFFFF: + raise SensorReadingValueError("Lux reading saturated (raw=0xFFFF)") + + lux = self._light_sensor.lux + except SensorReadingValueError: + # Propagate value errors (e.g., saturation) directly + raise + except Exception as e: + raise SensorReadingUnknownError("Failed to get lux reading") from e + + if lux is None or lux == 0: + raise SensorReadingValueError("Lux reading is invalid or zero") + return Lux(lux) + + def reset(self) -> None: + """Best-effort reset by toggling shutdown bit in configuration.""" + try: + # Set shutdown bit + conf_sd = self._light_sensor._encode_als_conf( + sd=True, it_code=0, gain_code=0 + ) + self._light_sensor._write_u16(self._light_sensor._REG_ALS_CONF, conf_sd) + time.sleep(0.1) + # Clear shutdown bit (enable) + conf_en = self._light_sensor._encode_als_conf( + sd=False, it_code=0, gain_code=0 + ) + self._light_sensor._write_u16(self._light_sensor._REG_ALS_CONF, conf_en) + self._log.debug("Light sensor reset successfully") + except Exception as e: + self._log.error("Failed to reset light sensor:", e) diff --git a/cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/light_sensor/manager/test_veml6031x00_manager.py b/cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/light_sensor/manager/test_veml6031x00_manager.py new file mode 100644 index 00000000..1d710045 --- /dev/null +++ b/cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/light_sensor/manager/test_veml6031x00_manager.py @@ -0,0 +1,167 @@ +"""Test the VEML6031X00Manager class.""" + +from typing import Generator +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from pysquared.hardware.exception import HardwareInitializationError +from pysquared.hardware.light_sensor.manager.veml6031x00 import VEML6031X00Manager +from pysquared.logger import Logger +from pysquared.sensor_reading.error import ( + SensorReadingUnknownError, + SensorReadingValueError, +) +from pysquared.sensor_reading.light import Light +from pysquared.sensor_reading.lux import Lux + + +@pytest.fixture +def mock_i2c(): + """Fixture to mock the I2C bus.""" + return MagicMock() + + +@pytest.fixture +def mock_logger(): + """Fixture to mock the logger.""" + return MagicMock(Logger) + + +@pytest.fixture +def mock_veml6031x00(mock_i2c: MagicMock) -> Generator[MagicMock, None, None]: + """Mocks the internal _VEML6031X00 driver class. + + Yields: + MagicMock class for _VEML6031X00 + """ + with patch( + "pysquared.hardware.light_sensor.manager.veml6031x00._VEML6031X00" + ) as mock_class: + mock_instance = MagicMock() + mock_instance.light = 1000.0 + mock_instance.lux = 500.0 + mock_class.return_value = mock_instance + yield mock_class + + +def test_create_light_sensor(mock_veml6031x00, mock_i2c, mock_logger): + """Verify successful creation of the manager and driver init logging.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + assert sensor._light_sensor is not None + mock_logger.debug.assert_called_once_with("Initializing light sensor") + + +def test_create_light_sensor_failed(mock_veml6031x00, mock_i2c, mock_logger): + """Ensure initialization failure raises HardwareInitializationError.""" + mock_veml6031x00.side_effect = Exception("Simulated VEML6031X00 failure") + with pytest.raises(HardwareInitializationError): + _ = VEML6031X00Manager(mock_logger, mock_i2c) + mock_logger.debug.assert_called_with("Initializing light sensor") + + +def test_get_light_success(mock_veml6031x00, mock_i2c, mock_logger): + """Read non-unit light value successfully and wrap in Light.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + sensor._light_sensor = MagicMock() + sensor._light_sensor.light = 1234.0 + + light = sensor.get_light() + assert isinstance(light, Light) + assert light.value == pytest.approx(1234.0, rel=1e-6) + + +def test_get_light_failure(mock_veml6031x00, mock_i2c, mock_logger): + """Propagate read exception as SensorReadingUnknownError for light.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + mock_instance = MagicMock() + sensor._light_sensor = mock_instance + mock_prop = PropertyMock(side_effect=RuntimeError("Simulated retrieval error")) + type(sensor._light_sensor).light = mock_prop + + with pytest.raises(SensorReadingUnknownError): + sensor.get_light() + + +def test_get_lux_success(mock_veml6031x00, mock_i2c, mock_logger): + """Read lux successfully and wrap in Lux.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + sensor._light_sensor = MagicMock() + sensor._light_sensor.lux = 321.0 + + lux = sensor.get_lux() + assert isinstance(lux, Lux) + assert lux.value == pytest.approx(321.0, rel=1e-6) + + +def test_get_lux_failure(mock_veml6031x00, mock_i2c, mock_logger): + """Propagate read exception as SensorReadingUnknownError for lux.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + mock_instance = MagicMock() + sensor._light_sensor = mock_instance + mock_prop = PropertyMock(side_effect=RuntimeError("Simulated retrieval error")) + type(sensor._light_sensor).lux = mock_prop + + with pytest.raises(SensorReadingUnknownError): + sensor.get_lux() + + +def test_get_lux_zero_reading(mock_veml6031x00, mock_i2c, mock_logger): + """Zero lux is invalid and should raise SensorReadingValueError.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + sensor._light_sensor = MagicMock() + sensor._light_sensor.lux = 0.0 + + with pytest.raises(SensorReadingValueError): + sensor.get_lux() + + +def test_get_lux_none_reading(mock_veml6031x00, mock_i2c, mock_logger): + """None lux is invalid and should raise SensorReadingValueError.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + sensor._light_sensor = MagicMock() + sensor._light_sensor.lux = None + + with pytest.raises(SensorReadingValueError): + sensor.get_lux() + + +def test_get_lux_saturation_raw(mock_veml6031x00, mock_i2c, mock_logger): + """Raw ambient-light of 0xFFFF indicates saturation and should raise value error.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + # Inject a driver where _read_u16 returns 0xFFFF + driver = MagicMock() + driver._REG_ALS = 0x04 + driver._read_u16.return_value = 0xFFFF + # lux property won't be reached but provide a sane value + type(driver).lux = PropertyMock(return_value=100.0) + sensor._light_sensor = driver + + with pytest.raises(SensorReadingValueError): + sensor.get_lux() + + +def test_reset_success(mock_veml6031x00, mock_i2c, mock_logger): + """Reset toggles shutdown bit and logs success.""" + with patch("time.sleep"): + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + # inject a fake driver with encode/write methods + driver = MagicMock() + driver._encode_als_conf.return_value = 0x0000 + sensor._light_sensor = driver + + sensor.reset() + # Two writes: SD on, then SD off + assert driver._write_u16.call_count == 2 + mock_logger.debug.assert_called_with("Light sensor reset successfully") + + +def test_reset_failure(mock_veml6031x00, mock_i2c, mock_logger): + """Reset failure is logged at error level.""" + sensor = VEML6031X00Manager(mock_logger, mock_i2c) + driver = MagicMock() + driver._encode_als_conf.return_value = 0x0001 + driver._write_u16.side_effect = RuntimeError("Simulated reset error") + sensor._light_sensor = driver + + sensor.reset() + mock_logger.error.assert_called_once()