diff --git a/lib/wsen-hids/README.md b/lib/wsen-hids/README.md new file mode 100644 index 00000000..0c7b8493 --- /dev/null +++ b/lib/wsen-hids/README.md @@ -0,0 +1,252 @@ +# WSEN-HIDS MicroPython Driver + +MicroPython driver for the **Würth Elektronik WSEN-HIDS** humidity and temperature sensor. + +The driver provides an easy-to-use API for reading **relative humidity** and **temperature** using the sensor’s **I²C interface**. + +The implementation is designed for **MicroPython** and integrates with the **STeaMi ecosystem**. + +--- + +# Features + +* I²C communication +* Relative humidity measurement +* Temperature measurement +* One-shot measurement mode +* Continuous measurement mode +* Configurable internal averaging +* Heater control +* Calibration handling +* Data-ready status helpers + +--- + +# Supported Sensor + +This driver targets: + +**WSEN-HIDS – 2525020210001** + +Main characteristics: + +| Parameter | Value | +| -------------------- | ----------------- | +| Interface | I²C | +| Default I²C address | `0x5F` | +| Humidity range | 0–100 %RH | +| Temperature range | −40 °C to +120 °C | +| Humidity accuracy | ±1.8 %RH | +| Temperature accuracy | ±0.2 °C | + +--- + +# Quick Example + +```python +from machine import I2C, Pin +from time import sleep +from wsen_hids import WSEN_HIDS + +i2c = I2C( + 0, + scl=Pin(9), + sda=Pin(8), + freq=100000, +) + +sensor = WSEN_HIDS(i2c) + +while True: + humidity, temperature = sensor.read_one_shot() + + print("Humidity: {:.2f} %RH".format(humidity)) + print("Temperature: {:.2f} °C".format(temperature)) + + sleep(1) +``` + +--- + +# API Overview + +## Initialization + +```python +sensor = WSEN_HIDS(i2c) +``` + +Optional parameters: + +```python +sensor = WSEN_HIDS( + i2c, + address=0x5F, + check_device=True, + enable_bdu=True, +) +``` + +--- + +# Reading Measurements + +### Combined reading + +```python +humidity, temperature = sensor.read() +``` + +### Humidity only + +```python +humidity = sensor.humidity() +``` + +### Temperature only + +```python +temperature = sensor.temperature() +``` + +--- + +### Measurement behavior + +After initialization, the sensor operates in **one-shot mode** (ODR = 00). +If `read()`, `humidity()`, or `temperature()` are called while the sensor is not in continuous mode, the driver **automatically triggers a one-shot conversion** to ensure fresh data is returned. + +This allows simple usage: + +```python +humidity, temperature = sensor.read() + +Continuous measurements can be enabled with sensor.set_continuous_mode(). + +# One-Shot Measurement + +Trigger a single conversion: + +```python +humidity, temperature = sensor.read_one_shot() +``` + +You can specify a timeout: + +```python +sensor.read_one_shot(timeout_ms=500) +``` + +--- + +# Continuous Measurement Mode + +Start continuous measurements: + +```python +sensor.set_continuous_mode(WSEN_HIDS.ODR_1_HZ) +``` + +Available output data rates: + +```python +WSEN_HIDS.ODR_1_HZ +WSEN_HIDS.ODR_7_HZ +WSEN_HIDS.ODR_12_5_HZ +``` + +Return to one-shot mode: + +```python +sensor.set_one_shot_mode() +``` + +--- + +# Averaging Configuration + +Configure internal measurement averaging: + +```python +sensor.set_average(avg_t=WSEN_HIDS.AVG_16, avg_h=WSEN_HIDS.AVG_16) +``` + +Available options: + +``` +AVG_2 +AVG_4 +AVG_8 +AVG_16 +AVG_32 +AVG_64 +AVG_128 +AVG_256 +``` + +Higher averaging improves noise performance but increases conversion time. + +--- + +# Heater Control + +The sensor contains an internal heater to help remove condensation. + +Enable heater: + +```python +sensor.enable_heater(True) +``` + +Disable heater: + +```python +sensor.enable_heater(False) +``` + +⚠️ Measurements should **not be taken while the heater is active**. + +--- + +# Status Helpers + +Check measurement readiness: + +```python +sensor.status() +sensor.humidity_ready() +sensor.temperature_ready() +sensor.data_ready() +``` + +These helpers read the **STATUS register** and indicate when fresh data is available. + +--- + +# Calibration + +The driver automatically reads the sensor’s **factory calibration coefficients** during initialization and applies the conversion formulas internally. + +No user calibration is required. + +--- + +# Examples + +Example scripts are located in: + +``` +examples/ +``` + +Examples include: + +* basic one-shot measurements +* continuous measurement mode +* driver validation tests + +Run an example using: + +```bash +mpremote mount lib/wsen-hids run lib/wsen-hids/examples/continuous_mode.py +``` \ No newline at end of file diff --git a/lib/wsen-hids/examples/continuous_mode.py b/lib/wsen-hids/examples/continuous_mode.py new file mode 100644 index 00000000..182c7365 --- /dev/null +++ b/lib/wsen-hids/examples/continuous_mode.py @@ -0,0 +1,19 @@ +from machine import I2C +from time import sleep + +from wsen_hids import WSEN_HIDS + +i2c = I2C(1) + +sensor = WSEN_HIDS(i2c) + +sensor.set_continuous_mode(WSEN_HIDS.ODR_1_HZ) + +for _ in range(10): + humidity, temperature = sensor.read() + + print("Humidity: {:.2f} %RH".format(humidity)) + print("Temperature: {:.2f} °C".format(temperature)) + print() + + sleep(1) diff --git a/lib/wsen-hids/examples/full_test.py b/lib/wsen-hids/examples/full_test.py new file mode 100644 index 00000000..0fec5581 --- /dev/null +++ b/lib/wsen-hids/examples/full_test.py @@ -0,0 +1,494 @@ +from machine import I2C +from time import sleep, sleep_ms + +from wsen_hids import WSEN_HIDS +from wsen_hids.const import * + +# --------------------------------------------------------------------- +# Update these pins and bus number to match your board +# --------------------------------------------------------------------- +MIN_HUMIDITY = 0.0 +MAX_HUMIDITY = 100.0 +MIN_TEMPERATURE = -40.0 +MAX_TEMPERATURE = 120.0 + +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 read_reg(sensor, reg): + return sensor._read_reg(reg) + + +def dump_registers(sensor): + print("DEVICE_ID = 0x{:02X}".format(read_reg(sensor, REG_DEVICE_ID))) + print("AV_CONF = 0x{:02X}".format(read_reg(sensor, REG_AV_CONF))) + print("CTRL_1 = 0x{:02X}".format(read_reg(sensor, REG_CTRL_1))) + print("CTRL_2 = 0x{:02X}".format(read_reg(sensor, REG_CTRL_2))) + print("CTRL_3 = 0x{:02X}".format(read_reg(sensor, REG_CTRL_3))) + print("STATUS = 0x{:02X}".format(read_reg(sensor, REG_STATUS))) + print("H_OUT_L = 0x{:02X}".format(read_reg(sensor, REG_H_OUT_L))) + print("H_OUT_H = 0x{:02X}".format(read_reg(sensor, REG_H_OUT_H))) + print("T_OUT_L = 0x{:02X}".format(read_reg(sensor, REG_T_OUT_L))) + print("T_OUT_H = 0x{:02X}".format(read_reg(sensor, REG_T_OUT_H))) + + +def test_i2c_scan(i2c): + print_header("1) I2C scan") + devices = i2c.scan() + print("I2C devices found:", [hex(x) for x in devices]) + + if WSEN_HIDS_I2C_ADDRESS in devices: + print_pass("WSEN-HIDS address found") + return True + else: + print_fail("WSEN-HIDS address found") + return False + + +def test_device_id(sensor): + print_header("2) Device ID") + + try: + dev_id = sensor.device_id() + print("Device ID:", hex(dev_id)) + + if dev_id == WSEN_HIDS_DEVICE_ID: + print_pass("Device ID matches 0x{:02X}".format(WSEN_HIDS_DEVICE_ID)) + return True + else: + print_fail( + "Device ID matches 0x{:02X}".format(WSEN_HIDS_DEVICE_ID), + hex(dev_id), + ) + return False + + except Exception as err: + print_fail("Device ID", err) + return False + + +def test_default_registers(sensor): + print_header("3) Default driver configuration") + try: + dump_registers(sensor) + + ctrl1 = read_reg(sensor, REG_CTRL_1) + av_conf = read_reg(sensor, REG_AV_CONF) + + bdu_ok = bool(ctrl1 & CTRL_1_BDU) + + # Driver defaults proposed: + # avg_t = AVG_16, avg_h = AVG_16 + avg_t = (av_conf >> 3) & 0x07 + avg_h = av_conf & 0x07 + avg_ok = (avg_t == AVG_16) and (avg_h == AVG_16) + + if bdu_ok: + print_pass("BDU enabled") + else: + print_fail("BDU enabled") + + if avg_ok: + print_pass("Default averaging configuration") + else: + print_fail( + "Default averaging configuration", + "AVG_T={}, AVG_H={}".format(avg_t, avg_h), + ) + + return bdu_ok and avg_ok + + except Exception as err: + print_fail("Default driver configuration", err) + return False + + +def test_reboot_memory(sensor): + print_header("4) Reboot memory") + try: + sensor.reboot_memory() + sleep(0.05) + dump_registers(sensor) + + if sensor.device_id() == WSEN_HIDS_DEVICE_ID: + print_pass("Reboot memory") + return True + else: + print_fail("Reboot memory", "device ID mismatch after reboot") + return False + + except Exception as err: + print_fail("Reboot memory", err) + return False + + +def test_one_shot(sensor): + print_header("5) One-shot read") + + try: + humidity_rh, temperature_c = sensor.read_one_shot(timeout_ms=500) + + status = sensor.status() + h_ready = sensor.humidity_ready() + t_ready = sensor.temperature_ready() + ready = sensor.data_ready() + + print("Humidity : {:.2f} %RH".format(humidity_rh)) + print("Temperature : {:.2f} °C".format(temperature_c)) + print("STATUS : 0x{:02X}".format(status)) + print("humidity_ready :", h_ready) + print("temperature_ready:", t_ready) + print("data_ready :", ready) + + humidity_ok = MIN_HUMIDITY <= humidity_rh <= MAX_HUMIDITY + temperature_ok = MIN_TEMPERATURE <= temperature_c <= MAX_TEMPERATURE + + if humidity_ok: + print_pass("Humidity is in a valid range") + else: + print_fail("Humidity is in a valid range", humidity_rh) + + if temperature_ok: + print_pass("Temperature is in a valid range") + else: + print_fail("Temperature is in a valid range", temperature_c) + + return humidity_ok and temperature_ok + + except Exception as err: + print_fail("One-shot read", err) + return False + + +def test_one_shot_loop(sensor, count=5, delay_s=1): + print_header("6) One-shot loop") + + ok = True + + try: + for i in range(count): + humidity_rh, temperature_c = sensor.read_one_shot(timeout_ms=500) + + print( + "#{:d} H={:.2f} %RH T={:.2f} °C".format( + i + 1, + humidity_rh, + temperature_c, + ) + ) + + if not (MIN_HUMIDITY <= humidity_rh <= MAX_HUMIDITY): + ok = False + + sleep(delay_s) + + if ok: + print_pass("One-shot loop") + else: + print_fail("One-shot loop", "invalid humidity value detected") + + return ok + + except Exception as err: + print_fail("One-shot loop", err) + return False + + +def test_continuous_mode(sensor, odr, label, wait_ms=1500, loops=5, delay_s=0.5): + print_header("7) Continuous mode - {}".format(label)) + + try: + sensor.set_continuous_mode(odr=odr) + + ctrl1 = read_reg(sensor, REG_CTRL_1) + pd_ok = bool(ctrl1 & CTRL_1_PD) + odr_ok = (ctrl1 & CTRL_1_ODR_MASK) == odr + + print("CTRL_1 after set_continuous_mode = 0x{:02X}".format(ctrl1)) + + if pd_ok: + print_pass("PD bit set") + else: + print_fail("PD bit set") + + if odr_ok: + print_pass("ODR configured correctly") + else: + print_fail("ODR configured correctly", "CTRL_1=0x{:02X}".format(ctrl1)) + + print("Waiting {} ms for fresh samples...".format(wait_ms)) + sleep_ms(wait_ms) + + ok = pd_ok and odr_ok + + last_h = None + last_t = None + + for i in range(loops): + humidity_rh, temperature_c = sensor.read() + status = sensor.status() + + print( + "#{:d} H={:.2f} %RH T={:.2f} °C STATUS=0x{:02X}".format( + i + 1, + humidity_rh, + temperature_c, + status, + ) + ) + + if not (MIN_HUMIDITY <= humidity_rh <= MAX_HUMIDITY): + ok = False + + if not (MIN_TEMPERATURE <= temperature_c <= MAX_TEMPERATURE): + ok = False + + if last_h is not None and abs(humidity_rh - last_h) > 50: + ok = False + + if last_t is not None and abs(temperature_c - last_t) > 30: + ok = False + + last_h = humidity_rh + last_t = temperature_c + + sleep(delay_s) + + sensor.set_one_shot_mode() + + if ok: + print_pass("Continuous mode - {}".format(label)) + else: + print_fail("Continuous mode - {}".format(label), "invalid sample detected") + + return ok + + except Exception as err: + print_fail("Continuous mode - {}".format(label), err) + return False + + +def test_status_helpers(sensor): + print_header("8) STATUS helpers") + + try: + sensor.set_continuous_mode(odr=ODR_1_HZ) + sleep(1.5) + + status = sensor.status() + h_avail = sensor.humidity_ready() + t_avail = sensor.temperature_ready() + ready = sensor.data_ready() + + print("STATUS = 0x{:02X}".format(status)) + print("humidity_ready() =", h_avail) + print("temperature_ready() =", t_avail) + print("data_ready() =", ready) + + sensor.set_one_shot_mode() + + # At least one indicator must match STATUS + flags_match = ( + h_avail == bool(status & STATUS_H_DA) + and t_avail == bool(status & STATUS_T_DA) + ) + + if flags_match: + print_pass("STATUS helper methods") + return True + else: + print_fail("STATUS helper methods", "helpers do not match STATUS bits") + return False + + except Exception as err: + print_fail("STATUS helper methods", err) + return False + + +def test_unitary_methods(sensor): + print_header("9) humidity() and temperature()") + + try: + sensor.set_continuous_mode(odr=ODR_1_HZ) + sleep(1.2) + + humidity_rh = sensor.humidity() + temperature_c = sensor.temperature() + + print("humidity() = {:.2f} %RH".format(humidity_rh)) + print("temperature() = {:.2f} °C".format(temperature_c)) + + sensor.set_one_shot_mode() + + humidity_ok = MIN_HUMIDITY <= humidity_rh <= MAX_HUMIDITY + temperature_ok = MIN_TEMPERATURE <= temperature_c <= MAX_TEMPERATURE + + if humidity_ok: + print_pass("humidity() valid") + else: + print_fail("humidity() valid", humidity_rh) + + if temperature_ok: + print_pass("temperature() valid") + else: + print_fail("temperature() valid", temperature_c) + + return humidity_ok and temperature_ok + + except Exception as err: + print_fail("humidity() and temperature()", err) + return False + + +def test_heater(sensor): + print_header("10) Heater control") + + try: + sensor.enable_heater(True) + sleep_ms(20) + ctrl2_on = read_reg(sensor, REG_CTRL_2) + heater_on_ok = bool(ctrl2_on & CTRL_2_HEATER) + + print("CTRL_2 with heater ON = 0x{:02X}".format(ctrl2_on)) + + sensor.enable_heater(False) + sleep_ms(20) + ctrl2_off = read_reg(sensor, REG_CTRL_2) + heater_off_ok = not bool(ctrl2_off & CTRL_2_HEATER) + + print("CTRL_2 with heater OFF = 0x{:02X}".format(ctrl2_off)) + + if heater_on_ok: + print_pass("Heater enable") + else: + print_fail("Heater enable") + + if heater_off_ok: + print_pass("Heater disable") + else: + print_fail("Heater disable") + + return heater_on_ok and heater_off_ok + + except Exception as err: + print_fail("Heater control", err) + return False + + +def test_average_configuration(sensor): + print_header("11) Average configuration") + + try: + sensor.set_average(avg_t=AVG_8, avg_h=AVG_4) + av_conf_1 = read_reg(sensor, REG_AV_CONF) + avg_t_1 = (av_conf_1 >> 3) & 0x07 + avg_h_1 = av_conf_1 & 0x07 + + print("AV_CONF (AVG_T=AVG_8, AVG_H=AVG_4) = 0x{:02X}".format(av_conf_1)) + + ok1 = (avg_t_1 == AVG_8) and (avg_h_1 == AVG_4) + + sensor.set_average(avg_t=AVG_64, avg_h=AVG_32) + av_conf_2 = read_reg(sensor, REG_AV_CONF) + avg_t_2 = (av_conf_2 >> 3) & 0x07 + avg_h_2 = av_conf_2 & 0x07 + + print("AV_CONF (AVG_T=AVG_64, AVG_H=AVG_32) = 0x{:02X}".format(av_conf_2)) + + ok2 = (avg_t_2 == AVG_64) and (avg_h_2 == AVG_32) + + # restore defaults + sensor.set_average(avg_t=AVG_16, avg_h=AVG_16) + + if ok1: + print_pass("Average configuration set #1") + else: + print_fail("Average configuration set #1") + + if ok2: + print_pass("Average configuration set #2") + else: + print_fail("Average configuration set #2") + + return ok1 and ok2 + + except Exception as err: + print_fail("Average configuration", err) + return False + + +def test_register_dump_after_reads(sensor): + print_header("12) Register dump after measurements") + + try: + sensor.read_one_shot(timeout_ms=500) + dump_registers(sensor) + print_pass("Register dump after measurements") + return True + except Exception as err: + print_fail("Register dump after measurements", err) + return False + + +def main(): + print_header("WSEN-HIDS 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_HIDS(i2c) + print_pass("Driver init") + 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_reboot_memory(sensor)) + results.append(test_one_shot(sensor)) + results.append(test_one_shot_loop(sensor, count=5, delay_s=1)) + results.append(test_continuous_mode(sensor, ODR_1_HZ, "1 Hz", wait_ms=1500)) + results.append(test_continuous_mode(sensor, ODR_7_HZ, "7 Hz", wait_ms=500)) + results.append(test_continuous_mode(sensor, ODR_12_5_HZ, "12.5 Hz", wait_ms=300)) + results.append(test_status_helpers(sensor)) + results.append(test_unitary_methods(sensor)) + results.append(test_heater(sensor)) + results.append(test_average_configuration(sensor)) + results.append(test_register_dump_after_reads(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-hids/examples/one_shot_mode.py b/lib/wsen-hids/examples/one_shot_mode.py new file mode 100644 index 00000000..3496868d --- /dev/null +++ b/lib/wsen-hids/examples/one_shot_mode.py @@ -0,0 +1,17 @@ +from machine import I2C +from time import sleep + +from wsen_hids import WSEN_HIDS + +i2c = I2C(1) + +sensor = WSEN_HIDS(i2c) + +for _ in range(10): + humidity, temperature = sensor.read_one_shot() + + print("Humidity: {:.2f} %RH".format(humidity)) + print("Temperature: {:.2f} °C".format(temperature)) + print() + + sleep(1) diff --git a/lib/wsen-hids/manifest.py b/lib/wsen-hids/manifest.py new file mode 100644 index 00000000..01b51f7f --- /dev/null +++ b/lib/wsen-hids/manifest.py @@ -0,0 +1,6 @@ +metadata( + description="Driver for the WSEN-HIDS humidity and temperature sensor.", + version="0.0.1", +) + +package("wsen_hids") diff --git a/lib/wsen-hids/wsen_hids/__init__.py b/lib/wsen-hids/wsen_hids/__init__.py new file mode 100644 index 00000000..b9470c4f --- /dev/null +++ b/lib/wsen-hids/wsen_hids/__init__.py @@ -0,0 +1,3 @@ +from .device import WSEN_HIDS + +__all__ = ["WSEN_HIDS"] diff --git a/lib/wsen-hids/wsen_hids/const.py b/lib/wsen-hids/wsen_hids/const.py new file mode 100644 index 00000000..02d4dec1 --- /dev/null +++ b/lib/wsen-hids/wsen_hids/const.py @@ -0,0 +1,82 @@ +""" +Constants for the WSEN-HIDS 2525020210001 humidity and temperature sensor. +""" + +from micropython import const + +WSEN_HIDS_I2C_ADDRESS = const(0x5F) +WSEN_HIDS_DEVICE_ID = const(0xBC) + +# Register map +REG_DEVICE_ID = const(0x0F) +REG_AV_CONF = const(0x10) +REG_CTRL_1 = const(0x20) +REG_CTRL_2 = const(0x21) +REG_CTRL_3 = const(0x22) +REG_STATUS = const(0x27) +REG_H_OUT_L = const(0x28) +REG_H_OUT_H = const(0x29) +REG_T_OUT_L = const(0x2A) +REG_T_OUT_H = const(0x2B) + +# Calibration registers +REG_H0_RH_X2 = const(0x30) +REG_H1_RH_X2 = const(0x31) +REG_T0_DEGC_X8 = const(0x32) +REG_T1_DEGC_X8 = const(0x33) +REG_T1_T0_MSB = const(0x35) +REG_H0_T0_OUT_L = const(0x36) +REG_H0_T0_OUT_H = const(0x37) +REG_H1_T0_OUT_L = const(0x3A) +REG_H1_T0_OUT_H = const(0x3B) +REG_T0_OUT_L = const(0x3C) +REG_T0_OUT_H = const(0x3D) +REG_T1_OUT_L = const(0x3E) +REG_T1_OUT_H = const(0x3F) + +# Multi-byte read auto increment for I2C +AUTO_INCREMENT = const(0x80) + +# CTRL_1 bits +CTRL_1_PD = const(1 << 7) +CTRL_1_BDU = const(1 << 2) +CTRL_1_ODR_MASK = const(0x03) + +# Output data rate values +ODR_ONE_SHOT = const(0x00) +ODR_1_HZ = const(0x01) +ODR_7_HZ = const(0x02) +ODR_12_5_HZ = const(0x03) + +# CTRL_2 bits +CTRL_2_BOOT = const(1 << 7) +CTRL_2_HEATER = const(1 << 3) +CTRL_2_ONE_SHOT = const(1 << 0) + +# CTRL_3 bits +CTRL_3_DRDY_H_L = const(1 << 7) +CTRL_3_PP_OD = const(1 << 6) +CTRL_3_DRDY_EN = const(1 << 2) + +# STATUS bits +STATUS_H_DA = const(1 << 1) +STATUS_T_DA = const(1 << 0) + +# Average register masks +AVG_T_MASK = const(0x38) +AVG_H_MASK = const(0x07) + +# Average presets +AVG_2 = const(0x00) +AVG_4 = const(0x01) +AVG_8 = const(0x02) +AVG_16 = const(0x03) +AVG_32 = const(0x04) +AVG_64 = const(0x05) +AVG_128 = const(0x06) +AVG_256 = const(0x07) + +# Default averaging according to the manual: +# temperature = 16 samples, humidity = 16 samples +AVG_T_DEFAULT = AVG_16 +AVG_H_DEFAULT = AVG_16 diff --git a/lib/wsen-hids/wsen_hids/device.py b/lib/wsen-hids/wsen_hids/device.py new file mode 100644 index 00000000..d185ca9b --- /dev/null +++ b/lib/wsen-hids/wsen_hids/device.py @@ -0,0 +1,307 @@ +from time import sleep_ms, ticks_ms, ticks_diff + +from .const import * +from .exceptions import ( + WSENHIDSError, + WSENHIDSCommunicationError, + WSENHIDSDeviceIDError, + WSENHIDSTimeoutError, +) + + +class WSEN_HIDS: + """ + MicroPython driver for the WSEN-HIDS 2525020210001. + + Public API: + - read() + - humidity() + - temperature() + - read_one_shot() + - set_continuous_mode() + - set_one_shot_mode() + - enable_bdu() + - enable_heater() + - set_average() + - status() + - data_ready() + """ + + ODR_ONE_SHOT = ODR_ONE_SHOT + ODR_1_HZ = ODR_1_HZ + ODR_7_HZ = ODR_7_HZ + ODR_12_5_HZ = ODR_12_5_HZ + + AVG_2 = AVG_2 + AVG_4 = AVG_4 + AVG_8 = AVG_8 + AVG_16 = AVG_16 + AVG_32 = AVG_32 + AVG_64 = AVG_64 + AVG_128 = AVG_128 + AVG_256 = AVG_256 + + DEFAULT_ONE_SHOT_TIMEOUT_MS = 100 + DEFAULT_BOOT_TIME_MS = 10 + + def __init__( + self, + i2c, + address=WSEN_HIDS_I2C_ADDRESS, + check_device=True, + enable_bdu=True, + avg_t=AVG_T_DEFAULT, + avg_h=AVG_H_DEFAULT, + ): + self.i2c = i2c + self.address = address + + self._buffer_1 = bytearray(1) + + self._calibration = {} + + if check_device: + self.check_device() + + self.set_average(avg_t, avg_h) + + if enable_bdu: + self.enable_bdu(True) + + self._read_calibration() + + # ------------------------------------------------------------------------- + # Low-level helpers + # ------------------------------------------------------------------------- + + def _read_reg(self, reg): + try: + self.i2c.readfrom_mem_into(self.address, reg, self._buffer_1) + return self._buffer_1[0] + except OSError as exc: + raise WSENHIDSCommunicationError("Unable to read register 0x{:02X}".format(reg)) from exc + + def _write_reg(self, reg, value): + try: + self._buffer_1[0] = value & 0xFF + self.i2c.writeto_mem(self.address, reg, self._buffer_1) + except OSError as exc: + raise WSENHIDSCommunicationError("Unable to write register 0x{:02X}".format(reg)) from exc + + def _read_regs(self, reg, length): + try: + return self.i2c.readfrom_mem(self.address, reg, length) + except OSError as exc: + raise WSENHIDSCommunicationError( + "Unable to read {} bytes from register 0x{:02X}".format(length, reg) + ) from exc + + def _update_reg(self, reg, mask, value): + current = self._read_reg(reg) + current &= ~mask + current |= (value & mask) + self._write_reg(reg, current) + + def _read_u16_le(self, reg_l): + data = self._read_regs(reg_l, 2) + return data[0] | (data[1] << 8) + + def _read_s16_le(self, reg_l): + value = self._read_u16_le(reg_l) + if value & 0x8000: + value -= 0x10000 + return value + + @staticmethod + def _clamp(value, minimum, maximum): + if value < minimum: + return minimum + if value > maximum: + return maximum + return value + + # ------------------------------------------------------------------------- + # Identification / calibration + # ------------------------------------------------------------------------- + + def device_id(self): + return self._read_reg(REG_DEVICE_ID) + + def check_device(self): + device_id = self.device_id() + if device_id != WSEN_HIDS_DEVICE_ID: + raise WSENHIDSDeviceIDError( + "Invalid device ID 0x{:02X}, expected 0x{:02X}".format( + device_id, WSEN_HIDS_DEVICE_ID + ) + ) + return True + + def _read_calibration(self): + h0_rh_x2 = self._read_reg(REG_H0_RH_X2) + h1_rh_x2 = self._read_reg(REG_H1_RH_X2) + t0_degC_x8_lsb = self._read_reg(REG_T0_DEGC_X8) + t1_degC_x8_lsb = self._read_reg(REG_T1_DEGC_X8) + t1_t0_msb = self._read_reg(REG_T1_T0_MSB) + + t0_degC_x8 = ((t1_t0_msb & 0x03) << 8) | t0_degC_x8_lsb + t1_degC_x8 = ((t1_t0_msb & 0x0C) << 6) | t1_degC_x8_lsb + + self._calibration = { + "H0_rH": h0_rh_x2 / 2.0, + "H1_rH": h1_rh_x2 / 2.0, + "H0_T0_OUT": self._read_s16_le(REG_H0_T0_OUT_L), + "H1_T0_OUT": self._read_s16_le(REG_H1_T0_OUT_L), + "T0_degC": t0_degC_x8 / 8.0, + "T1_degC": t1_degC_x8 / 8.0, + "T0_OUT": self._read_s16_le(REG_T0_OUT_L), + "T1_OUT": self._read_s16_le(REG_T1_OUT_L), + } + + # ------------------------------------------------------------------------- + # Configuration + # ------------------------------------------------------------------------- + + def enable_bdu(self, enabled=True): + value = CTRL_1_BDU if enabled else 0 + self._update_reg(REG_CTRL_1, CTRL_1_BDU, value) + + def set_average(self, avg_t=AVG_T_DEFAULT, avg_h=AVG_H_DEFAULT): + avg_t &= 0x07 + avg_h &= 0x07 + value = (avg_t << 3) | avg_h + self._write_reg(REG_AV_CONF, value) + + def set_one_shot_mode(self): + ctrl1 = self._read_reg(REG_CTRL_1) + ctrl1 |= CTRL_1_PD # sensor active + ctrl1 &= ~CTRL_1_ODR_MASK # ODR = 00 => one-shot + self._write_reg(REG_CTRL_1, ctrl1) + + def set_continuous_mode(self, odr=ODR_1_HZ): + if odr not in (ODR_1_HZ, ODR_7_HZ, ODR_12_5_HZ): + raise ValueError("Invalid ODR for continuous mode") + + ctrl1 = self._read_reg(REG_CTRL_1) + ctrl1 |= CTRL_1_PD + ctrl1 &= ~CTRL_1_ODR_MASK + ctrl1 |= odr + self._write_reg(REG_CTRL_1, ctrl1) + + def enable_heater(self, enabled=True): + value = CTRL_2_HEATER if enabled else 0 + self._update_reg(REG_CTRL_2, CTRL_2_HEATER, value) + + def reboot_memory(self): + self._update_reg(REG_CTRL_2, CTRL_2_BOOT, CTRL_2_BOOT) + sleep_ms(self.DEFAULT_BOOT_TIME_MS) + self._read_calibration() + + # ------------------------------------------------------------------------- + # Status + # ------------------------------------------------------------------------- + + def status(self): + return self._read_reg(REG_STATUS) + + def humidity_ready(self): + return bool(self.status() & STATUS_H_DA) + + def temperature_ready(self): + return bool(self.status() & STATUS_T_DA) + + def data_ready(self): + status = self.status() + return bool((status & STATUS_H_DA) and (status & STATUS_T_DA)) + + # ------------------------------------------------------------------------- + # Raw reads / conversions + # ------------------------------------------------------------------------- + + def _read_raw_humidity_temperature(self): + # Multi-byte read with auto-increment bit set. + data = self._read_regs(REG_H_OUT_L | AUTO_INCREMENT, 4) + + h_raw = data[0] | (data[1] << 8) + t_raw = data[2] | (data[3] << 8) + + if h_raw & 0x8000: + h_raw -= 0x10000 + if t_raw & 0x8000: + t_raw -= 0x10000 + + return h_raw, t_raw + + def _convert_humidity(self, h_raw): + h0_rh = self._calibration["H0_rH"] + h1_rh = self._calibration["H1_rH"] + h0_out = self._calibration["H0_T0_OUT"] + h1_out = self._calibration["H1_T0_OUT"] + + delta_out = h1_out - h0_out + if delta_out == 0: + raise WSENHIDSError("Invalid humidity calibration data") + + humidity = ((h1_rh - h0_rh) * (h_raw - h0_out) / delta_out) + h0_rh + return self._clamp(humidity, 0.0, 100.0) + + def _convert_temperature(self, t_raw): + t0_degC = self._calibration["T0_degC"] + t1_degC = self._calibration["T1_degC"] + t0_out = self._calibration["T0_OUT"] + t1_out = self._calibration["T1_OUT"] + + delta_out = t1_out - t0_out + if delta_out == 0: + raise WSENHIDSError("Invalid temperature calibration data") + + return ((t1_degC - t0_degC) * (t_raw - t0_out) / delta_out) + t0_degC + + # ------------------------------------------------------------------------- + # Public measurement API + # ------------------------------------------------------------------------- + + def _is_power_down(self): + ctrl1 = self._read_reg(REG_CTRL_1) + return (ctrl1 & CTRL_1_PD) == 0 + + def _ensure_data(self, timeout_ms=DEFAULT_ONE_SHOT_TIMEOUT_MS): + if not self._is_power_down(): + return + + self.trigger_one_shot() + + start = ticks_ms() + while not self.data_ready(): + if ticks_diff(ticks_ms(), start) >= timeout_ms: + raise WSENHIDSTimeoutError("One-shot measurement timeout") + sleep_ms(1) + + def read(self): + self._ensure_data() + + h_raw, t_raw = self._read_raw_humidity_temperature() + humidity = self._convert_humidity(h_raw) + temperature = self._convert_temperature(t_raw) + return humidity, temperature + + def humidity(self): + return self.read()[0] + + def temperature(self): + return self.read()[1] + + def trigger_one_shot(self): + self.set_one_shot_mode() + self._update_reg(REG_CTRL_2, CTRL_2_ONE_SHOT, CTRL_2_ONE_SHOT) + + def read_one_shot(self, timeout_ms=DEFAULT_ONE_SHOT_TIMEOUT_MS): + self.trigger_one_shot() + + start = ticks_ms() + while not self.data_ready(): + if ticks_diff(ticks_ms(), start) >= timeout_ms: + raise WSENHIDSTimeoutError("One-shot measurement timeout") + sleep_ms(1) + + return self.read() diff --git a/lib/wsen-hids/wsen_hids/exceptions.py b/lib/wsen-hids/wsen_hids/exceptions.py new file mode 100644 index 00000000..8434ef91 --- /dev/null +++ b/lib/wsen-hids/wsen_hids/exceptions.py @@ -0,0 +1,14 @@ +class WSENHIDSError(Exception): + """Base exception for the WSEN-HIDS driver.""" + + +class WSENHIDSCommunicationError(WSENHIDSError): + """Raised when an I2C communication error occurs.""" + + +class WSENHIDSDeviceIDError(WSENHIDSError): + """Raised when the device ID does not match the expected value.""" + + +class WSENHIDSTimeoutError(WSENHIDSError): + """Raised when a conversion does not complete before timeout.""" diff --git a/tests/runner/executor.py b/tests/runner/executor.py index 1d72bbd8..c50be477 100644 --- a/tests/runner/executor.py +++ b/tests/runner/executor.py @@ -35,22 +35,29 @@ def load_driver_mock(driver_name, fake_i2c, module_name=None): # Patch time module to add MicroPython-specific functions import time + # Use a monotonic clock to emulate MicroPython's ticks_* semantics + monotonic = getattr(time, "monotonic", time.perf_counter) + if not hasattr(time, "sleep_ms"): time.sleep_ms = lambda ms: time.sleep(ms / 1000) if not hasattr(time, "sleep_us"): time.sleep_us = lambda us: time.sleep(us / 1000000) + if not hasattr(time, "ticks_ms"): + time.ticks_ms = lambda: int(monotonic() * 1000) + if not hasattr(time, "ticks_us"): + time.ticks_us = lambda: int(monotonic() * 1000000) + if not hasattr(time, "ticks_diff"): + time.ticks_diff = lambda a, b: a - b # 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 + utime.ticks_ms = time.ticks_ms + utime.ticks_us = time.ticks_us + utime.ticks_diff = time.ticks_diff sys.modules["utime"] = utime # Add driver lib path to sys.path diff --git a/tests/scenarios/wsen_hids.yaml b/tests/scenarios/wsen_hids.yaml new file mode 100644 index 00000000..23864fcf --- /dev/null +++ b/tests/scenarios/wsen_hids.yaml @@ -0,0 +1,103 @@ +driver: wsen-hids +module_name: wsen_hids +driver_class: WSEN_HIDS +i2c_address: 0x5F + +# I2C config for hardware tests (STeaMi board - STM32WB55) +i2c: + id: 1 + +# Register values for mock tests +# These simulate a real WSEN-HIDS sensor with calibration data +mock_registers: + # DEVICE_ID (expected 0xBC) + 0x0F: 0xBC + # AV_CONF (AVG_T=16, AVG_H=16 -> (3 << 3) | 3 = 0x1B) + 0x10: 0x1B + # CTRL_1 (power-down, BDU enabled) + 0x20: 0x04 + # CTRL_2 (default) + 0x21: 0x00 + # STATUS (humidity and temperature data available) + 0x27: 0x03 + + # Calibration registers + # H0_RH_X2 = 60 -> H0_rH = 30.0 % + 0x30: 0x3C + # H1_RH_X2 = 120 -> H1_rH = 60.0 % + 0x31: 0x78 + # T0_DEGC_X8 low byte = 0xA0 (T0_degC_x8 = 160 -> T0 = 20.0 C) + 0x32: 0xA0 + # T1_DEGC_X8 low byte = 0x18 (T1_degC_x8 = 280 -> T1 = 35.0 C) + 0x33: 0x18 + # T1_T0_MSB: bits[1:0]=T0 msb=0, bits[3:2]=T1 msb=1 -> 0x04 + 0x35: 0x04 + # H0_T0_OUT (signed16 = 3000 = 0x0BB8) + 0x36: [0xB8, 0x0B] + # H1_T0_OUT (signed16 = 9000 = 0x2328) + 0x3A: [0x28, 0x23] + # T0_OUT (signed16 = 2000 = 0x07D0) + 0x3C: [0xD0, 0x07] + # T1_OUT (signed16 = 5000 = 0x1388) + 0x3E: [0x88, 0x13] + + # Raw data via auto-increment read at 0xA8 (0x28 | 0x80) + # H_OUT_L, H_OUT_H, T_OUT_L, T_OUT_H + # h_raw = 7000 (-> 50.0 %RH), t_raw = 3000 (-> 25.0 C) + 0xA8: [0x58, 0x1B, 0xB8, 0x0B] + +tests: + - name: "Verify device ID register" + action: read_register + register: 0x0F + expect: 0xBC + mode: [mock, hardware] + + - name: "Read device ID via method" + action: call + method: device_id + expect: 0xBC + mode: [mock, hardware] + + - name: "Read status register" + action: call + method: status + expect_not_none: true + mode: [mock, hardware] + + - name: "Read humidity returns float" + action: call + method: humidity + expect_range: [49.0, 51.0] + mode: [mock] + + - name: "Read temperature returns float" + action: call + method: temperature + expect_range: [24.0, 26.0] + mode: [mock] + + - name: "Humidity in plausible range" + action: call + method: humidity + expect_range: [5.0, 100.0] + mode: [hardware] + + - name: "Temperature in plausible range" + action: call + method: temperature + expect_range: [-10.0, 60.0] + mode: [hardware] + + - name: "Humidity and temperature feel correct" + action: manual + display: + - method: humidity + label: "Humidity" + unit: "%RH" + - method: temperature + label: "Temperature" + unit: "°C" + prompt: "Ces valeurs sont-elles cohérentes (humidité ambiante, température ambiante) ?" + expect_true: true + mode: [hardware]