diff --git a/lib/wsen-pads/README.md b/lib/wsen-pads/README.md new file mode 100644 index 00000000..872672e3 --- /dev/null +++ b/lib/wsen-pads/README.md @@ -0,0 +1,220 @@ +# WSEN-PADS MicroPython Driver + +MicroPython driver for the **Würth Elektronik WSEN-PADS** absolute pressure sensor. + +This driver provides an easy-to-use API to read **pressure** and **temperature** using the I²C interface. + +The WSEN-PADS is a high-resolution digital pressure sensor with integrated temperature measurement, designed for applications such as weather monitoring, altimetry, and environmental sensing. + +--- + +# Features + +* I²C communication +* Pressure measurement +* Temperature measurement +* One-shot acquisition +* Continuous measurement mode +* Configurable Output Data Rate (ODR) +* Low-noise mode support +* Optional low-pass filter +* Soft reset and device reboot +* Raw data access + +--- + +# Sensor Overview + +| Feature | Value | +| ----------------------- | ---------------------- | +| Pressure range | 26 kPa – 126 kPa | +| Pressure resolution | 24-bit | +| Temperature resolution | 16-bit | +| Pressure sensitivity | 1 / 4096 hPa per digit | +| Temperature sensitivity | 0.01 °C per digit | +| Interface | I²C / SPI | +| Maximum ODR | 200 Hz | + +--- + +# Hardware Connection + +The driver currently supports **I²C mode**. + +## Pins + +| Sensor Pin | Description | +| ---------- | -------------------- | +| VDD | Power supply | +| VDD_IO | Interface supply | +| GND | Ground | +| SDA | I²C data | +| SCL | I²C clock | +| CS | Must be HIGH for I²C | +| SAO | Selects I²C address | + +## I²C Address + +| SAO | Address | +| ---- | ------- | +| LOW | `0x5C` | +| HIGH | `0x5D` | + +Recommended configuration for a single device on the bus: + +``` +SAO = HIGH +I2C address = 0x5D +``` + +--- + +# Installation + +Clone the repository and copy the driver to your MicroPython device. + +Example using **mpremote**: + +```bash +mpremote mount lib/wsen-pads +``` + +The driver will then be available as: + +```python +from wsen_pads import WSEN_PADS +``` + +--- + +# Basic Usage + +```python +from machine import I2C, Pin +from time import sleep +from wsen_pads import WSEN_PADS + +i2c = I2C( + 1, + scl=Pin(7), + sda=Pin(6), +) + +sensor = WSEN_PADS(i2c) + +while True: + pressure, temperature = sensor.read() + + print("Pressure:", pressure, "hPa") + print("Temperature:", temperature, "°C") + print() + + sleep(1) +``` + +--- + +# One-shot Measurement + +```python +pressure, temperature = sensor.read_one_shot() +``` + +This triggers a single conversion and returns the measurement. + +--- + +# Continuous Mode + +```python +from wsen_pads.const import ODR_10_HZ + +sensor.set_continuous(odr=ODR_10_HZ) + +pressure = sensor.pressure() +temperature = sensor.temperature() +``` + +Available ODR values: + +``` +ODR_1_HZ +ODR_10_HZ +ODR_25_HZ +ODR_50_HZ +ODR_75_HZ +ODR_100_HZ +ODR_200_HZ +``` + +--- + +# Raw Data Access + +Raw values from the sensor can also be read: + +```python +raw_pressure = sensor.pressure_raw() +raw_temperature = sensor.temperature_raw() +``` + +Conversions: + +``` +pressure_hpa = raw_pressure / 4096 +temperature_c = raw_temperature * 0.01 +``` + +--- + +# Status Helpers + +The driver provides helper functions for checking data availability. + +```python +sensor.pressure_available() +sensor.temperature_available() +sensor.is_ready() +``` + +--- + +# Device Control + +## Soft Reset + +```python +sensor.soft_reset() +``` + +## Reboot Sensor + +```python +sensor.reboot() +``` + +--- + +# Examples + +Examples are available in the `examples` directory. + +--- + +# Driver Structure + +``` +wsen-pads/ +│ +├── README.md +├── manifest.py +├── examples/ +│ +└── wsen_pads/ + ├── __init__.py + ├── const.py + ├── device.py + └── exceptions.py +``` + +--- diff --git a/lib/wsen-pads/examples/altitude.py b/lib/wsen-pads/examples/altitude.py new file mode 100644 index 00000000..4a30962c --- /dev/null +++ b/lib/wsen-pads/examples/altitude.py @@ -0,0 +1,24 @@ +from machine import I2C, Pin +from time import sleep +from wsen_pads import WSEN_PADS + +SEA_LEVEL_PRESSURE = 1013.25 # depends on your location, you can adjust it for better altitude estimation +EXPONENT = 0.1903 # standard atmosphere exponent for altitude calculation + +i2c = I2C(1) + +sensor = WSEN_PADS(i2c) + +def pressure_to_altitude(p): + return 44330 * (1 - (p / SEA_LEVEL_PRESSURE) ** EXPONENT) + +for _ in range(10): + pressure, temp = sensor.read() + + altitude = pressure_to_altitude(pressure) + + print("Pressure:", pressure, "hPa") + print("Altitude:", altitude, "m") + print() + + sleep(0.5) diff --git a/lib/wsen-pads/examples/basic_reader.py b/lib/wsen-pads/examples/basic_reader.py new file mode 100644 index 00000000..c08d4f38 --- /dev/null +++ b/lib/wsen-pads/examples/basic_reader.py @@ -0,0 +1,20 @@ +from machine import I2C, Pin +from time import sleep +from wsen_pads import WSEN_PADS + + +# Update the I2C bus number and pins to match your board +i2c = I2C(1) + +# Create the sensor object +pads = WSEN_PADS(i2c) + +print("WSEN-PADS found") +print("Device ID:", hex(pads.device_id())) + +for _ in range(10): + pressure_hpa, temperature_c = pads.read() + + print("P:", pressure_hpa, "hPa T:", temperature_c, "°C") + + sleep(0.5) diff --git a/lib/wsen-pads/examples/continuous_reader.py b/lib/wsen-pads/examples/continuous_reader.py new file mode 100644 index 00000000..30ad2ba8 --- /dev/null +++ b/lib/wsen-pads/examples/continuous_reader.py @@ -0,0 +1,18 @@ +from machine import I2C, Pin +from time import sleep +from wsen_pads import WSEN_PADS +from wsen_pads.const import ODR_10_HZ + +i2c = I2C(1) + +sensor = WSEN_PADS(i2c) + +sensor.set_continuous(odr=ODR_10_HZ) + +for _ in range(10): + pressure = sensor.pressure() + temperature = sensor.temperature() + + print("P:", pressure, "hPa T:", temperature, "°C") + + sleep(0.5) diff --git a/lib/wsen-pads/examples/one_shot_reader.py b/lib/wsen-pads/examples/one_shot_reader.py new file mode 100644 index 00000000..a0c4afcf --- /dev/null +++ b/lib/wsen-pads/examples/one_shot_reader.py @@ -0,0 +1,14 @@ +from machine import I2C, Pin +from time import sleep +from wsen_pads import WSEN_PADS + +i2c = I2C(1) + +sensor = WSEN_PADS(i2c) + +for _ in range(10): + pressure, temperature = sensor.read_one_shot() + + print("P:", pressure, "hPa T:", temperature, "°C") + + sleep(0.5) diff --git a/lib/wsen-pads/examples/test.py b/lib/wsen-pads/examples/test.py new file mode 100644 index 00000000..f6a78006 --- /dev/null +++ b/lib/wsen-pads/examples/test.py @@ -0,0 +1,296 @@ +from machine import I2C, Pin +from time import sleep +from wsen_pads import WSEN_PADS +from wsen_pads.const import ( + ODR_1_HZ, + ODR_10_HZ, + REG_CTRL_1, + REG_CTRL_2, + REG_STATUS, + REG_INT_SOURCE, +) + +def print_header(title): + print() + print("=" * 60) + print(title) + print("=" * 60) + + +def print_pass(name): + print("[PASS] {}".format(name)) + + +def print_fail(name, err=None): + if err is None: + print("[FAIL] {}".format(name)) + else: + print("[FAIL] {} -> {}".format(name, err)) + + +def dump_registers(sensor): + ctrl1 = sensor._read_u8(REG_CTRL_1) + ctrl2 = sensor._read_u8(REG_CTRL_2) + status = sensor._read_u8(REG_STATUS) + int_source = sensor._read_u8(REG_INT_SOURCE) + + print("CTRL_1 = 0x{:02X}".format(ctrl1)) + print("CTRL_2 = 0x{:02X}".format(ctrl2)) + print("STATUS = 0x{:02X}".format(status)) + print("INT_SOURCE = 0x{:02X}".format(int_source)) + + +def test_i2c_scan(i2c): + print_header("1) I2C scan") + devices = i2c.scan() + print("I2C devices found:", [hex(x) for x in devices]) + + if 0x5C in devices or 0x5D in devices: + print_pass("WSEN-PADS address found") + return True + else: + print_fail("WSEN-PADS address not found") + return False + + +def test_device_id(sensor): + print_header("2) Device ID") + dev_id = sensor.device_id() + print("Device ID:", hex(dev_id)) + + if dev_id == 0xB3: + print_pass("Device ID matches 0xB3") + return True + else: + print_fail("Device ID matches 0xB3", hex(dev_id)) + return False + + +def test_default_registers(sensor): + print_header("3) Default driver configuration") + dump_registers(sensor) + + ctrl1 = sensor._read_u8(REG_CTRL_1) + ctrl2 = sensor._read_u8(REG_CTRL_2) + + # BDU should be enabled by the driver + bdu_ok = bool(ctrl1 & 0x02) + + # IF_ADD_INC should be enabled by the driver + if_add_inc_ok = bool(ctrl2 & 0x10) + + if bdu_ok: + print_pass("BDU enabled") + else: + print_fail("BDU enabled") + + if if_add_inc_ok: + print_pass("IF_ADD_INC enabled") + else: + print_fail("IF_ADD_INC enabled") + + return bdu_ok and if_add_inc_ok + + +def test_soft_reset(sensor): + print_header("4) Soft reset") + try: + sensor.soft_reset() + sleep(0.05) + dump_registers(sensor) + + if sensor.device_id() == 0xB3: + print_pass("Soft reset") + return True + else: + print_fail("Soft reset", "device ID mismatch after reset") + return False + except Exception as err: + print_fail("Soft reset", err) + return False + + +def test_reboot(sensor): + print_header("5) Reboot") + try: + sensor.reboot() + sleep(1) + dump_registers(sensor) + + if sensor.device_id() == 0xB3: + print_pass("Reboot") + return True + else: + print_fail("Reboot", "device ID mismatch after reboot") + return False + except Exception as err: + print_fail("Reboot", err) + return False + + +def test_one_shot(sensor): + print_header("6) One-shot read") + + try: + pressure_hpa, temperature_c = sensor.read_one_shot() + + raw_p = sensor.pressure_raw() + raw_t = sensor.temperature_raw() + status = sensor.status() + + print("Raw pressure :", raw_p) + print("Raw temperature :", raw_t) + print("Pressure : {:.2f} hPa".format(pressure_hpa)) + print("Temperature : {:.2f} °C".format(temperature_c)) + print("STATUS : 0x{:02X}".format(status)) + + # Basic sanity checks + MIN_PRESSURE = 260.0 + MAX_PRESSURE = 1260.0 + MIN_TEMPERATURE = -40.0 + MAX_TEMPERATURE = 85.0 + pressure_ok = MIN_PRESSURE <= pressure_hpa <= MAX_PRESSURE + temperature_ok = MIN_TEMPERATURE <= temperature_c <= MAX_TEMPERATURE + raw_ok = not (raw_p == 0 and raw_t == 0) + + if raw_ok: + print_pass("Raw data is not all zero") + else: + print_fail("Raw data is not all zero") + + if pressure_ok: + print_pass("Pressure is in a valid range") + else: + print_fail("Pressure is in a valid range", pressure_hpa) + + if temperature_ok: + print_pass("Temperature is in a valid range") + else: + print_fail("Temperature is in a valid range", temperature_c) + + return raw_ok and pressure_ok and temperature_ok + + except Exception as err: + print_fail("One-shot read", err) + return False + + +def test_continuous_mode(sensor, odr, label, wait_s=2): + print_header("7) Continuous mode - {}".format(label)) + + try: + sensor.set_continuous(odr=odr) + print("Waiting {} second(s) for fresh samples...".format(wait_s)) + sleep(wait_s) + + ok = True + + for i in range(5): + pressure_hpa = sensor.pressure() + temperature_c = sensor.temperature() + raw_p = sensor.pressure_raw() + raw_t = sensor.temperature_raw() + status = sensor.status() + + print( + "#{:d} P={:.2f} hPa T={:.2f} °C rawP={} rawT={} STATUS=0x{:02X}".format( + i + 1, + pressure_hpa, + temperature_c, + raw_p, + raw_t, + status, + ) + ) + + if raw_p == 0 and raw_t == 0: + ok = False + + sleep(0.5) + + sensor.power_down() + + if ok: + print_pass("Continuous mode - {}".format(label)) + else: + print_fail("Continuous mode - {}".format(label), "raw data stayed at zero") + + return ok + + except Exception as err: + print_fail("Continuous mode - {}".format(label), err) + return False + + +def test_status_flags(sensor): + print_header("8) STATUS helpers") + + try: + sensor.set_continuous(odr=ODR_1_HZ) + sleep(1.5) + + status = sensor.status() + p_avail = sensor.pressure_available() + t_avail = sensor.temperature_available() + ready = sensor.is_ready() + + print("STATUS = 0x{:02X}".format(status)) + print("pressure_available() =", p_avail) + print("temperature_available() =", t_avail) + print("is_ready() =", ready) + + sensor.power_down() + + if p_avail or t_avail or ready: + print_pass("STATUS helper methods") + return True + else: + print_fail("STATUS helper methods") + return False + + except Exception as err: + print_fail("STATUS helper methods", err) + return False + + +def main(): + print_header("WSEN-PADS full driver test") + + i2c = I2C(1) + + if not test_i2c_scan(i2c): + print() + print("Stop: sensor not found on I2C bus.") + return + + try: + sensor = WSEN_PADS(i2c) + except Exception as err: + print_fail("Driver init", err) + return + + results = [] + + results.append(test_device_id(sensor)) + results.append(test_default_registers(sensor)) + results.append(test_soft_reset(sensor)) + results.append(test_reboot(sensor)) + results.append(test_one_shot(sensor)) + results.append(test_continuous_mode(sensor, ODR_1_HZ, "1 Hz", wait_s=2)) + results.append(test_continuous_mode(sensor, ODR_10_HZ, "10 Hz", wait_s=1)) + results.append(test_status_flags(sensor)) + + print_header("Final result") + + passed = sum(1 for x in results if x) + total = len(results) + + print("Passed: {}/{}".format(passed, total)) + + if passed == total: + print("All tests passed.") + else: + print("Some tests failed.") + + +main() diff --git a/lib/wsen-pads/manifest.py b/lib/wsen-pads/manifest.py new file mode 100644 index 00000000..4d9abb0e --- /dev/null +++ b/lib/wsen-pads/manifest.py @@ -0,0 +1,6 @@ +metadata( + description="Driver of WSEN-PADS Pressure and Temperature Sensor.", + version="0.0.1", +) + +package("wsen_pads") diff --git a/lib/wsen-pads/wsen_pads/__init__.py b/lib/wsen-pads/wsen_pads/__init__.py new file mode 100644 index 00000000..078f331a --- /dev/null +++ b/lib/wsen-pads/wsen_pads/__init__.py @@ -0,0 +1,3 @@ +from wsen_pads.device import WSEN_PADS + +__all__ = ["WSEN_PADS"] diff --git a/lib/wsen-pads/wsen_pads/const.py b/lib/wsen-pads/wsen_pads/const.py new file mode 100644 index 00000000..f5cfafd2 --- /dev/null +++ b/lib/wsen-pads/wsen_pads/const.py @@ -0,0 +1,145 @@ +""" +Constants for the WSEN-PADS pressure and temperature sensor. + +All register addresses and bit definitions in this file come from the +official WSEN-PADS user manual. +""" + +# ============================================================================ +# I2C addressing +# ============================================================================ + +# 7-bit I2C address when SAO pin is tied low +WSEN_PADS_I2C_ADDR_SAO_LOW = 0x5C + +# 7-bit I2C address when SAO pin is tied high +WSEN_PADS_I2C_ADDR_SAO_HIGH = 0x5D + +# Default address recommended for a single device on the bus +WSEN_PADS_I2C_DEFAULT_ADDR = WSEN_PADS_I2C_ADDR_SAO_HIGH + +# Expected device ID value +WSEN_PADS_DEVICE_ID = 0xB3 + +# ============================================================================ +# Register map +# ============================================================================ + +REG_INT_CFG = 0x0B +REG_THR_P_L = 0x0C +REG_THR_P_H = 0x0D +REG_INTERFACE_CTRL = 0x0E +REG_DEVICE_ID = 0x0F +REG_CTRL_1 = 0x10 +REG_CTRL_2 = 0x11 +REG_CTRL_3 = 0x12 +REG_FIFO_CTRL = 0x13 +REG_FIFO_WTM = 0x14 +REG_REF_P_L = 0x15 +REG_REF_P_H = 0x16 +REG_OPC_P_L = 0x18 +REG_OPC_P_H = 0x19 +REG_INT_SOURCE = 0x24 +REG_FIFO_STATUS_1 = 0x25 +REG_FIFO_STATUS_2 = 0x26 +REG_STATUS = 0x27 +REG_DATA_P_XL = 0x28 +REG_DATA_P_L = 0x29 +REG_DATA_P_H = 0x2A +REG_DATA_T_L = 0x2B +REG_DATA_T_H = 0x2C + +# FIFO output registers (not used yet in the V1 driver) +REG_FIFO_DATA_P_XL = 0x78 +REG_FIFO_DATA_P_L = 0x79 +REG_FIFO_DATA_P_H = 0x7A +REG_FIFO_DATA_T_L = 0x7B +REG_FIFO_DATA_T_H = 0x7C + +# ============================================================================ +# CTRL_1 register bits +# ============================================================================ + +# ODR[2:0] field occupies bits 6:4 +CTRL1_ODR_SHIFT = 4 +CTRL1_ODR_MASK = 0x70 + +# Enable second low-pass filter for pressure +CTRL1_EN_LPFP = 1 << 3 + +# Low-pass filter bandwidth configuration +CTRL1_LPFP_CFG = 1 << 2 + +# Block data update +CTRL1_BDU = 1 << 1 + +# SPI mode selection (not used in I2C mode) +CTRL1_SIM = 1 << 0 + +# ============================================================================ +# CTRL_2 register bits +# ============================================================================ + +CTRL2_BOOT = 1 << 7 +CTRL2_INT_H_L = 1 << 6 +CTRL2_PP_OD = 1 << 5 +CTRL2_IF_ADD_INC = 1 << 4 +CTRL2_SWRESET = 1 << 2 +CTRL2_LOW_NOISE_EN = 1 << 1 +CTRL2_ONE_SHOT = 1 << 0 + +# ============================================================================ +# INT_SOURCE register bits +# ============================================================================ + +INT_SOURCE_BOOT_ON = 1 << 7 +INT_SOURCE_IA = 1 << 2 +INT_SOURCE_PL = 1 << 1 +INT_SOURCE_PH = 1 << 0 + +# ============================================================================ +# STATUS register bits +# ============================================================================ + +STATUS_T_OR = 1 << 5 +STATUS_P_OR = 1 << 4 +STATUS_T_DA = 1 << 1 +STATUS_P_DA = 1 << 0 + +# ============================================================================ +# Output data rate (ODR) values for CTRL_1[6:4] +# ============================================================================ + +ODR_POWER_DOWN = 0x00 +ODR_1_HZ = 0x01 +ODR_10_HZ = 0x02 +ODR_25_HZ = 0x03 +ODR_50_HZ = 0x04 +ODR_75_HZ = 0x05 +ODR_100_HZ = 0x06 +ODR_200_HZ = 0x07 + +# ============================================================================ +# Conversion constants +# ============================================================================ + +# Pressure sensitivity: +# 1 digit = 1 / 40960 kPa = 1 / 4096 hPa = 1 / 40.96 Pa +PRESSURE_HPA_PER_DIGIT = 1.0 / 4096.0 +PRESSURE_KPA_PER_DIGIT = 1.0 / 40960.0 +PRESSURE_PA_PER_DIGIT = 1.0 / 40.96 + +# Temperature sensitivity: +# 1 digit = 0.01 °C +TEMPERATURE_C_PER_DIGIT = 0.01 + +# ============================================================================ +# Timing helpers +# ============================================================================ + +# Typical boot time after power-up is up to 4.5 ms, so 5 ms is a safe minimum. +BOOT_DELAY_MS = 5 + +# Typical conversion time in one-shot mode +ONE_SHOT_LOW_POWER_DELAY_MS = 5 +ONE_SHOT_LOW_NOISE_DELAY_MS = 15 diff --git a/lib/wsen-pads/wsen_pads/device.py b/lib/wsen-pads/wsen_pads/device.py new file mode 100644 index 00000000..c9142689 --- /dev/null +++ b/lib/wsen-pads/wsen_pads/device.py @@ -0,0 +1,412 @@ +from utime import sleep_ms, ticks_ms, ticks_diff + +from wsen_pads.const import * +from wsen_pads.exceptions import * + + +class WSEN_PADS: + """ + MicroPython driver for the Würth Elektronik WSEN-PADS pressure sensor. + + This V1 driver supports: + - I2C communication + - device identification + - pressure and temperature reading + - one-shot acquisition + - continuous mode configuration + - low-noise and low-pass filter basic configuration + - soft reset and reboot + """ + + def __init__(self, i2c, address=WSEN_PADS_I2C_DEFAULT_ADDR): + """ + Create a WSEN-PADS device instance. + + Parameters: + i2c: an initialized machine.I2C object + address: 7-bit I2C address of the sensor + """ + self.i2c = i2c + self.address = address + + # Wait for the sensor boot sequence after power-up. + sleep_ms(BOOT_DELAY_MS) + + # Check that the sensor is present on the I2C bus. + if not self._is_present(): + raise WSENPADSDeviceNotFound( + "WSEN-PADS not found at I2C address 0x{:02X}".format(self.address) + ) + + # Wait until the internal boot process is complete. + self._wait_boot() + + # Verify that the detected sensor is really a WSEN-PADS. + self._check_device() + + # Apply a safe default configuration. + self._configure_default() + + # --------------------------------------------------------------------- + # Low-level I2C helpers + # --------------------------------------------------------------------- + + def _is_present(self): + """Return True if the device address is visible on the I2C bus.""" + try: + return self.address in self.i2c.scan() + except Exception: + return False + + def _read_u8(self, reg): + """Read and return one unsigned byte from a register.""" + return self.i2c.readfrom_mem(self.address, reg, 1)[0] + + def _read(self, reg, length): + """Read and return multiple bytes starting at a register.""" + return self.i2c.readfrom_mem(self.address, reg, length) + + def _write_u8(self, reg, value): + """Write one unsigned byte to a register.""" + self.i2c.writeto_mem(self.address, reg, bytes((value & 0xFF,))) + + def _update_reg(self, reg, mask, value): + """ + Update selected bits in a register. + + Only the bits set in 'mask' are modified. Other bits are preserved. + """ + current = self._read_u8(reg) + current = (current & ~mask) | (value & mask) + self._write_u8(reg, current) + + # --------------------------------------------------------------------- + # Internal conversion helpers + # --------------------------------------------------------------------- + + @staticmethod + def _to_signed24(value): + """Convert a 24-bit integer to a signed Python integer.""" + if value & 0x800000: + value -= 0x1000000 + return value + + @staticmethod + def _to_signed16(value): + """Convert a 16-bit integer to a signed Python integer.""" + if value & 0x8000: + value -= 0x10000 + return value + + # --------------------------------------------------------------------- + # Internal device helpers + # --------------------------------------------------------------------- + + def _wait_boot(self, timeout_ms=20): + """ + Wait until the BOOT_ON flag is cleared. + + The sensor sets BOOT_ON while internal trimming parameters are loaded. + """ + start = ticks_ms() + while self._read_u8(REG_INT_SOURCE) & INT_SOURCE_BOOT_ON: + if ticks_diff(ticks_ms(), start) > timeout_ms: + raise WSENPADSTimeout("WSEN-PADS boot timeout") + sleep_ms(1) + + def _check_device(self): + """Raise an exception if the device ID does not match.""" + device_id = self.device_id() + if device_id != WSEN_PADS_DEVICE_ID: + raise WSENPADSInvalidDevice( + "Invalid WSEN-PADS device ID: 0x{:02X}".format(device_id) + ) + + def _configure_default(self): + """ + Apply a safe default configuration. + + Default choices: + - power-down mode + - block data update enabled + - register auto-increment enabled + - low-noise disabled + - low-pass filter disabled + """ + self.power_down() + + # Enable automatic register address increment. + self._update_reg(REG_CTRL_2, CTRL2_IF_ADD_INC, CTRL2_IF_ADD_INC) + + # Enable block data update to avoid partial register updates + # during multi-byte reads. + self._update_reg(REG_CTRL_1, CTRL1_BDU, CTRL1_BDU) + + # Make sure low-noise is disabled by default. + self._update_reg(REG_CTRL_2, CTRL2_LOW_NOISE_EN, 0) + + # --------------------------------------------------------------------- + # Identification and status + # --------------------------------------------------------------------- + + def device_id(self): + """Return the value of the DEVICE_ID register.""" + return self._read_u8(REG_DEVICE_ID) + + def status(self): + """Return the raw STATUS register value.""" + return self._read_u8(REG_STATUS) + + def pressure_available(self): + """Return True when new pressure data is available.""" + return bool(self.status() & STATUS_P_DA) + + def temperature_available(self): + """Return True when new temperature data is available.""" + return bool(self.status() & STATUS_T_DA) + + def is_ready(self): + """ + Return True when both pressure and temperature data are available. + + This is mainly useful in continuous mode. + """ + status = self.status() + return bool((status & STATUS_P_DA) and (status & STATUS_T_DA)) + + # --------------------------------------------------------------------- + # Power and reset control + # --------------------------------------------------------------------- + + def power_down(self): + """Put the device in power-down mode by setting ODR = 000.""" + self._update_reg(REG_CTRL_1, CTRL1_ODR_MASK, ODR_POWER_DOWN << CTRL1_ODR_SHIFT) + + def soft_reset(self): + """ + Trigger a software reset. + + This restores user registers to their default values. + """ + self._update_reg(REG_CTRL_2, CTRL2_SWRESET, CTRL2_SWRESET) + sleep_ms(1) + + # Re-apply the minimal driver configuration after reset. + self._configure_default() + + def reboot(self): + """ + Trigger a reboot of the internal memory content. + + This reloads trimming parameters from internal non-volatile memory. + """ + self._update_reg(REG_CTRL_2, CTRL2_BOOT, CTRL2_BOOT) + self._wait_boot() + + # Re-apply the minimal driver configuration after reboot. + self._configure_default() + + # --------------------------------------------------------------------- + # Raw data reading + # --------------------------------------------------------------------- + + def _is_power_down(self): + """Return True if the sensor is in power-down mode (ODR = 000).""" + return (self._read_u8(REG_CTRL_1) & CTRL1_ODR_MASK) == 0 + + def _ensure_data(self): + """Trigger a one-shot conversion if the sensor is in power-down mode.""" + if self._is_power_down(): + self.trigger_one_shot() + + def pressure_raw(self): + """ + Read and return raw pressure as a signed 24-bit integer. + + If the sensor is in power-down mode, a one-shot conversion is + triggered automatically before reading. + """ + self._ensure_data() + data = self._read(REG_DATA_P_XL, 3) + raw = (data[2] << 16) | (data[1] << 8) | data[0] + return self._to_signed24(raw) + + def temperature_raw(self): + """ + Read and return raw temperature as a signed 16-bit integer. + + If the sensor is in power-down mode, a one-shot conversion is + triggered automatically before reading. + """ + self._ensure_data() + data = self._read(REG_DATA_T_L, 2) + raw = (data[1] << 8) | data[0] + return self._to_signed16(raw) + + # --------------------------------------------------------------------- + # Converted data reading + # --------------------------------------------------------------------- + + def pressure(self): + """ + Read and return pressure in hPa. + """ + return self.pressure_raw() * PRESSURE_HPA_PER_DIGIT + + def pressure_pa(self): + """ + Read and return pressure in Pa. + """ + return self.pressure_raw() * PRESSURE_PA_PER_DIGIT + + def pressure_kpa(self): + """ + Read and return pressure in kPa. + """ + return self.pressure_raw() * PRESSURE_KPA_PER_DIGIT + + def temperature(self): + """ + Read and return temperature in degrees Celsius. + """ + return self.temperature_raw() * TEMPERATURE_C_PER_DIGIT + + def read(self): + """ + Read and return both pressure and temperature. + + A one-shot conversion is triggered to ensure fresh data. + + Returns: + tuple: (pressure_hpa, temperature_c) + """ + self.trigger_one_shot() + p_data = self._read(REG_DATA_P_XL, 3) + p_raw = self._to_signed24((p_data[2] << 16) | (p_data[1] << 8) | p_data[0]) + t_data = self._read(REG_DATA_T_L, 2) + t_raw = self._to_signed16((t_data[1] << 8) | t_data[0]) + return p_raw * PRESSURE_HPA_PER_DIGIT, t_raw * TEMPERATURE_C_PER_DIGIT + + # --------------------------------------------------------------------- + # One-shot mode + # --------------------------------------------------------------------- + + def trigger_one_shot(self, low_noise=False): + """ + Trigger a single conversion. + + The device must be in power-down mode before setting ONE_SHOT. + The function blocks until the typical conversion time has elapsed. + + Parameters: + low_noise: False for low-power mode, True for low-noise mode + """ + self.power_down() + + # LOW_NOISE_EN must only be changed while in power-down mode. + if low_noise: + self._update_reg(REG_CTRL_2, CTRL2_LOW_NOISE_EN, CTRL2_LOW_NOISE_EN) + else: + self._update_reg(REG_CTRL_2, CTRL2_LOW_NOISE_EN, 0) + + # Start one-shot conversion. + self._update_reg(REG_CTRL_2, CTRL2_ONE_SHOT, CTRL2_ONE_SHOT) + + # Wait for typical conversion completion time. + if low_noise: + sleep_ms(ONE_SHOT_LOW_NOISE_DELAY_MS) + else: + sleep_ms(ONE_SHOT_LOW_POWER_DELAY_MS) + + def read_one_shot(self, low_noise=False): + """ + Trigger one conversion and return converted pressure and temperature. + + Returns: + tuple: (pressure_hpa, temperature_c) + """ + return self.read() + + # --------------------------------------------------------------------- + # Continuous mode + # --------------------------------------------------------------------- + + def set_continuous( + self, + odr=ODR_1_HZ, + low_noise=False, + low_pass=False, + low_pass_strong=False, + ): + """ + Configure continuous measurement mode. + + Parameters: + odr: one of the ODR_* constants + low_noise: enable low-noise mode + low_pass: enable LPF2 on pressure output + low_pass_strong: when LPF2 is enabled: + False -> bandwidth ODR/9 + True -> bandwidth ODR/20 + """ + if odr not in ( + ODR_1_HZ, + ODR_10_HZ, + ODR_25_HZ, + ODR_50_HZ, + ODR_75_HZ, + ODR_100_HZ, + ODR_200_HZ, + ): + raise ValueError("Invalid ODR value") + + # Low-noise mode is not allowed at 100 Hz and 200 Hz. + if low_noise and odr in (ODR_100_HZ, ODR_200_HZ): + raise ValueError("Low-noise mode is not available at 100 Hz or 200 Hz") + + # Enter power-down before changing LOW_NOISE_EN as required by the sensor. + self.power_down() + + # Configure low-noise mode and auto-increment. + ctrl2_value = CTRL2_IF_ADD_INC + if low_noise: + ctrl2_value |= CTRL2_LOW_NOISE_EN + self._update_reg( + REG_CTRL_2, + CTRL2_IF_ADD_INC | CTRL2_LOW_NOISE_EN, + ctrl2_value, + ) + + # Build CTRL_1 configuration. + ctrl1_value = CTRL1_BDU | (odr << CTRL1_ODR_SHIFT) + + if low_pass: + ctrl1_value |= CTRL1_EN_LPFP + if low_pass_strong: + ctrl1_value |= CTRL1_LPFP_CFG + + self._write_u8(REG_CTRL_1, ctrl1_value) + + # --------------------------------------------------------------------- + # Optional helper methods + # --------------------------------------------------------------------- + + def enable_low_pass(self, strong=False): + """ + Enable the optional LPF2 pressure filter. + + This helper preserves the current ODR and only updates filter bits. + """ + current = self._read_u8(REG_CTRL_1) + current |= CTRL1_EN_LPFP + if strong: + current |= CTRL1_LPFP_CFG + else: + current &= ~CTRL1_LPFP_CFG + self._write_u8(REG_CTRL_1, current) + + def disable_low_pass(self): + """Disable the optional LPF2 pressure filter.""" + current = self._read_u8(REG_CTRL_1) + current &= ~(CTRL1_EN_LPFP | CTRL1_LPFP_CFG) + self._write_u8(REG_CTRL_1, current) diff --git a/lib/wsen-pads/wsen_pads/exceptions.py b/lib/wsen-pads/wsen_pads/exceptions.py new file mode 100644 index 00000000..ccacd267 --- /dev/null +++ b/lib/wsen-pads/wsen_pads/exceptions.py @@ -0,0 +1,14 @@ +class WSENPADSError(Exception): + """Base exception for all WSEN-PADS driver errors.""" + + +class WSENPADSDeviceNotFound(WSENPADSError): + """Raised when the device does not respond on the I2C bus.""" + + +class WSENPADSInvalidDevice(WSENPADSError): + """Raised when the detected device ID does not match WSEN-PADS.""" + + +class WSENPADSTimeout(WSENPADSError): + """Raised when a blocking operation exceeds the expected timeout.""" diff --git a/tests/runner/executor.py b/tests/runner/executor.py index 83eca79c..1d72bbd8 100644 --- a/tests/runner/executor.py +++ b/tests/runner/executor.py @@ -5,12 +5,20 @@ from pathlib import Path -def load_driver_mock(driver_name, fake_i2c): +def load_driver_mock(driver_name, fake_i2c, module_name=None): """Load a driver using FakeI2C on CPython. Patches machine and micropython modules, imports the driver, and returns an instance configured with the fake I2C bus. + + Args: + driver_name: directory name under lib/ (e.g. 'wsen-pads') + fake_i2c: FakeI2C instance with pre-loaded registers + module_name: Python module name if different from driver_name + (e.g. 'wsen_pads' when dir is 'wsen-pads') """ + if module_name is None: + module_name = driver_name from tests.fake_machine import FakeI2C, FakePin from tests.fake_machine import micropython_stub @@ -32,6 +40,19 @@ def load_driver_mock(driver_name, fake_i2c): if not hasattr(time, "sleep_us"): time.sleep_us = lambda us: time.sleep(us / 1000000) + # Create utime module as alias for time (MicroPython compatibility) + if "utime" not in sys.modules: + # Use a monotonic clock to emulate MicroPython's ticks_* semantics + monotonic = getattr(time, "monotonic", time.perf_counter) + utime = types.ModuleType("utime") + utime.sleep_ms = time.sleep_ms + utime.sleep_us = time.sleep_us + utime.sleep = time.sleep + utime.ticks_ms = lambda: int(monotonic() * 1000) + utime.ticks_us = lambda: int(monotonic() * 1000000) + utime.ticks_diff = lambda a, b: a - b + sys.modules["utime"] = utime + # Add driver lib path to sys.path root = Path(__file__).parent.parent.parent driver_lib = root / "lib" / driver_name @@ -40,17 +61,18 @@ def load_driver_mock(driver_name, fake_i2c): # Force reimport of the driver module for mod_name in list(sys.modules): - if mod_name.startswith(driver_name): + if mod_name.startswith(module_name): del sys.modules[mod_name] - driver_module = importlib.import_module(f"{driver_name}.device") + driver_module = importlib.import_module(f"{module_name}.device") return driver_module, fake_i2c -def cleanup_driver(driver_name): +def cleanup_driver(driver_name, module_name=None): """Remove patched modules after test.""" + mod_prefix = module_name or driver_name for mod_name in list(sys.modules): - if mod_name.startswith(driver_name): + if mod_name.startswith(mod_prefix): del sys.modules[mod_name] sys.modules.pop("machine", None) sys.modules.pop("micropython", None) diff --git a/tests/runner/mpremote_bridge.py b/tests/runner/mpremote_bridge.py index 8ff81583..705f19b1 100644 --- a/tests/runner/mpremote_bridge.py +++ b/tests/runner/mpremote_bridge.py @@ -64,8 +64,12 @@ def _driver_dir(self, driver_name): """Return the local lib path for a driver.""" return PROJECT_ROOT / "lib" / driver_name - def call_method(self, driver_name, driver_class, i2c_config, method, args=None, i2c_address=None): + def call_method( + self, driver_name, driver_class, i2c_config, method, + args=None, i2c_address=None, module_name=None, + ): """Call a method on a driver instance and return the result.""" + mod = module_name or driver_name args_str = ", ".join(repr(a) for a in (args or [])) i2c_init = _i2c_init_code(i2c_config) if i2c_address is not None: @@ -75,7 +79,7 @@ def call_method(self, driver_name, driver_class, i2c_config, method, args=None, code = ( f"import json\n" f"{i2c_init}\n" - f"from {driver_name}.device import {driver_class}\n" + f"from {mod}.device import {driver_class}\n" f"{dev_init}" f"result = dev.{method}({args_str})\n" f"print(json.dumps(result))" diff --git a/tests/scenarios/wsen_pads.yaml b/tests/scenarios/wsen_pads.yaml new file mode 100644 index 00000000..cf70c695 --- /dev/null +++ b/tests/scenarios/wsen_pads.yaml @@ -0,0 +1,84 @@ +driver: wsen-pads +module_name: wsen_pads +driver_class: WSEN_PADS +i2c_address: 0x5D + +# I2C config for hardware tests (STeaMi board - STM32WB55) +i2c: + id: 1 + +# Register values for mock tests +# These simulate a real WSEN-PADS sensor +mock_registers: + # DEVICE_ID (expected 0xB3) + 0x0F: 0xB3 + # INT_SOURCE (BOOT_ON=0, no interrupts) + 0x24: 0x00 + # CTRL_1 (power down, BDU enabled) + 0x10: 0x02 + # CTRL_2 (auto-increment enabled) + 0x11: 0x10 + # STATUS (pressure and temperature data available) + 0x27: 0x03 + # INT_CFG (default) + 0x0B: 0x00 + # DATA_P_XL..DATA_P_H (simulated ~1013.25 hPa: 1013.25 * 4096 = 4150272 = 0x3F5400) + 0x28: [0x00, 0x54, 0x3F] + # DATA_T_L..DATA_T_H (simulated ~25.00°C: 25.00 / 0.01 = 2500 = 0x09C4) + 0x2B: [0xC4, 0x09] + +tests: + - name: "Verify device ID register" + action: read_register + register: 0x0F + expect: 0xB3 + mode: [mock, hardware] + + - name: "Read device ID via method" + action: call + method: device_id + expect: 0xB3 + mode: [mock, hardware] + + - name: "Read status register" + action: call + method: status + expect_not_none: true + mode: [mock, hardware] + + - name: "Read pressure returns float" + action: call + method: pressure + expect_not_none: true + mode: [mock] + + - name: "Read temperature returns float" + action: call + method: temperature + expect_not_none: true + mode: [mock] + + - name: "Pressure in plausible range" + action: call + method: pressure + expect_range: [900.0, 1100.0] + mode: [hardware] + + - name: "Temperature in plausible range" + action: call + method: temperature + expect_range: [-10.0, 60.0] + mode: [hardware] + + - name: "Pressure and temperature feel correct" + action: manual + display: + - method: pressure + label: "Pressure" + unit: "hPa" + - method: temperature + label: "Temperature" + unit: "°C" + prompt: "Ces valeurs sont-elles cohérentes (pression ~1013 hPa, température ambiante) ?" + expect_true: true + mode: [hardware] diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index b6ca0264..3906501c 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -45,6 +45,7 @@ def iter_scenario_tests(): def make_mock_instance(scenario): """Create a driver instance using FakeI2C from scenario data.""" driver_name = scenario["driver"] + module_name = scenario.get("module_name", driver_name) driver_class = scenario["driver_class"] address = scenario.get("i2c_address", 0x00) mock_registers = scenario.get("mock_registers", {}) @@ -56,7 +57,7 @@ def make_mock_instance(scenario): registers[key] = v fake_i2c = FakeI2C(registers=registers, address=address) - driver_module, _ = load_driver_mock(driver_name, fake_i2c) + driver_module, _ = load_driver_mock(driver_name, fake_i2c, module_name=module_name) cls = getattr(driver_module, driver_class) instance = cls(fake_i2c, address=address) @@ -90,6 +91,7 @@ def test_scenario(scenario, test, mode, port): scenario["i2c"], display["method"], display.get("args"), + module_name=scenario.get("module_name"), ) label = display.get("label", display["method"]) unit = display.get("unit", "") @@ -107,6 +109,7 @@ def test_scenario(scenario, test, mode, port): scenario["i2c"], test["method"], test.get("args"), + module_name=scenario.get("module_name"), ) elif action == "read_register": result = bridge.read_register(