From 4ed7be8c6ef537f8f3739dbcbd5e5a982252d345 Mon Sep 17 00:00:00 2001 From: gabriel Date: Mon, 3 Aug 2026 14:38:06 +0200 Subject: [PATCH] feat: expose SHT40 ambient temperature and humidity Boards with an SHT40 (reTerminal E1001/E1002/E1003/E1004, Sticky) broadcast the reading in the advertisement's dynamic block, so these entities need no connection and no polling -- the passive coordinator already has the data. Requires py-opendisplay 7.15.0, which added the decoder. Unlike the existing chip temperature these are primary entities: not diagnostic, and enabled by default. Ambient room temperature is what a user expects to find on the device. The block's offset within the dynamic area is per-board and cannot be assumed: E1001/E1002/E1004 use 1 while the firmware default is 7, and decoding at the wrong offset yields plausible-looking garbage (a button byte of 0x28 reads as -39.9 C). It is therefore taken from the device's own config and captured per entity at setup. Rename the chip temperature to "Chip temperature" so it is distinguishable from the new ambient one, which would otherwise take the same device-class name. This is a translation_key only; the entity key -- and so the unique_id -- stays "temperature", so existing entities keep their id and history. Also pass the configured button byte indices to AdvertisementTracker. Only the bytes a BinaryInputs packet claims are buttons; the rest belong to touch controllers and sensors and decode into valid-looking button reports, so without this a refreshed SHT40 reading emits phantom button transitions. event.py filters by byte_index before firing an entity, so nothing was user-visible, but the events were produced on every advertisement. --- custom_components/opendisplay/__init__.py | 2 +- custom_components/opendisplay/coordinator.py | 27 +++- custom_components/opendisplay/manifest.json | 2 +- custom_components/opendisplay/sensor.py | 61 ++++++++- custom_components/opendisplay/strings.json | 3 + .../opendisplay/translations/en.json | 3 + tests/test_sht40_sensors.py | 118 ++++++++++++++++++ 7 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 tests/test_sht40_sensors.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 2297b75..137f934 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -281,7 +281,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) from .update import _format_firmware_version profile = SleepProfile.from_entry(entry, device_config) - coordinator = OpenDisplayCoordinator(hass, address) + coordinator = OpenDisplayCoordinator(hass, address, device_config.binary_inputs) manufacturer = device_config.manufacturer display = device_config.displays[0] diff --git a/custom_components/opendisplay/coordinator.py b/custom_components/opendisplay/coordinator.py index 2858ff4..520c762 100644 --- a/custom_components/opendisplay/coordinator.py +++ b/custom_components/opendisplay/coordinator.py @@ -11,6 +11,7 @@ TouchChangeEvent, TouchTracker, ) +from opendisplay.models.config import BinaryInputs from homeassistant.components.bluetooth import ( BluetoothChange, @@ -40,8 +41,20 @@ class OpenDisplayUpdate: class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): """Coordinator for passive BLE advertisement updates from an OpenDisplay device.""" - def __init__(self, hass: HomeAssistant, address: str) -> None: - """Initialize the coordinator.""" + def __init__( + self, + hass: HomeAssistant, + address: str, + binary_inputs: list[BinaryInputs] | None = None, + ) -> None: + """Initialize the coordinator. + + binary_inputs comes from the device config and tells the tracker which + bytes of the advertisement's dynamic block are really buttons. The rest + belong to touch controllers and sensors and decode into valid-looking + button reports, so without it a moving touch coordinate or a refreshed + SHT40 reading produces phantom button transitions. + """ super().__init__( hass, _LOGGER, @@ -50,7 +63,15 @@ def __init__(self, hass: HomeAssistant, address: str) -> None: connectable=True, ) self.data: OpenDisplayUpdate | None = None - self._tracker: AdvertisementTracker = AdvertisementTracker() + self._tracker: AdvertisementTracker = AdvertisementTracker( + None + if binary_inputs is None + else [ + index + for bi in binary_inputs + if (index := bi.published_button_byte_index) is not None + ] + ) self.touch_trackers: list[TouchTracker] = [] # Subscribers notified once when the advertised reboot flag goes # False -> True (the device rebooted since we last talked to it). diff --git a/custom_components/opendisplay/manifest.json b/custom_components/opendisplay/manifest.json index 73d5883..39a61db 100644 --- a/custom_components/opendisplay/manifest.json +++ b/custom_components/opendisplay/manifest.json @@ -15,6 +15,6 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/OpenDisplay/Home_Assistant_Integration/issues", "loggers": ["opendisplay"], - "requirements": ["py-opendisplay[silabs-ota]==7.14.1", "odl-renderer==0.5.12"], + "requirements": ["py-opendisplay[silabs-ota]==7.15.0", "odl-renderer==0.5.12"], "version": "3.0.0-beta.9" } diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 8107ed0..29ff665 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -6,7 +6,9 @@ import time from opendisplay import voltage_to_percent -from opendisplay.models.enums import CapacityEstimator, PowerMode +from opendisplay.models.advertisement import Sht40Reading +from opendisplay.models.config import SensorData +from opendisplay.models.enums import CapacityEstimator, PowerMode, SensorType from homeassistant.components.bluetooth import async_last_service_info from homeassistant.components.sensor import ( @@ -39,8 +41,12 @@ class OpenDisplaySensorEntityDescription(SensorEntityDescription): value_fn: Callable[[OpenDisplayUpdate], float | int | str | datetime | None] +# The MCU's own temperature, not an attached sensor. translation_key only sets +# the display name; the key -- and so the unique_id -- stays "temperature", so +# entities that already exist keep their history. _TEMPERATURE_DESCRIPTION = OpenDisplaySensorEntityDescription( key="temperature", + translation_key="chip_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -49,6 +55,52 @@ class OpenDisplaySensorEntityDescription(SensorEntityDescription): value_fn=lambda upd: upd.advertisement.temperature_c, ) + +def _sht40_descriptions( + sensor: SensorData, +) -> list[OpenDisplaySensorEntityDescription]: + """Build ambient temperature and humidity entities for one SHT40. + + The reading rides in the advertisement, so these need no connection. Its + offset within the dynamic block is per-board and cannot be assumed -- + reTerminal E1001/E1002/E1004 use 1 while the firmware default is 7 -- so it + comes from the device's own config and is captured once per entity here. + + Unlike the chip temperature these are primary entities: not diagnostic, and + enabled by default. + """ + start_byte = sensor.sht40_msd_start_byte + + def _reading(upd: OpenDisplayUpdate) -> Sht40Reading | None: + return upd.advertisement.sht40_reading(start_byte) + + def _temperature(upd: OpenDisplayUpdate) -> float | None: + reading = _reading(upd) + return None if reading is None else reading.temperature_c + + def _humidity(upd: OpenDisplayUpdate) -> float | None: + reading = _reading(upd) + return None if reading is None else reading.humidity_percent + + return [ + OpenDisplaySensorEntityDescription( + key=f"sht40_{sensor.instance_number}_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=_temperature, + ), + OpenDisplaySensorEntityDescription( + key=f"sht40_{sensor.instance_number}_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=_humidity, + ), + ] + _BATTERY_POWER_MODES = {PowerMode.BATTERY, PowerMode.SOLAR} _BATTERY_VOLTAGE_DESCRIPTION = OpenDisplaySensorEntityDescription( @@ -92,13 +144,18 @@ async def async_setup_entry( ) -> None: """Set up OpenDisplay sensor entities.""" coordinator = entry.runtime_data.coordinator - power_config = entry.runtime_data.device_config.power + device_config = entry.runtime_data.device_config + power_config = device_config.power descriptions: list[OpenDisplaySensorEntityDescription] = [ _TEMPERATURE_DESCRIPTION, _RSSI_DESCRIPTION, _LAST_SEEN_DESCRIPTION, ] + for sensor in device_config.sensors: + if sensor.sensor_type_enum is SensorType.SHT40: + descriptions += _sht40_descriptions(sensor) + if power_config.power_mode_enum in _BATTERY_POWER_MODES: capacity_estimator = power_config.capacity_estimator or CapacityEstimator.LI_ION descriptions += [ diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 3efbbe8..b6c7998 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -109,6 +109,9 @@ } }, "sensor": { + "chip_temperature": { + "name": "Chip temperature" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index eae8fe1..62e8454 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -109,6 +109,9 @@ } }, "sensor": { + "chip_temperature": { + "name": "Chip temperature" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/tests/test_sht40_sensors.py b/tests/test_sht40_sensors.py new file mode 100644 index 0000000..8c69ca6 --- /dev/null +++ b/tests/test_sht40_sensors.py @@ -0,0 +1,118 @@ +"""Unit tests for the SHT40 ambient temperature/humidity sensors. + +These avoid the full Home Assistant test harness: the entity descriptions are +built directly from a SensorData packet and their ``value_fn`` is applied to a +hand-built coordinator update, which is the whole of the decode path. +""" + +from unittest.mock import MagicMock + +from opendisplay.models.advertisement import parse_advertisement +from opendisplay.models.config import SensorData +from opendisplay.models.enums import SensorType + +from custom_components.opendisplay.coordinator import OpenDisplayUpdate +from custom_components.opendisplay.sensor import ( + _TEMPERATURE_DESCRIPTION, + _sht40_descriptions, +) + +ADDRESS = "AA:BB:CC:DD:EE:FF" +READING = bytes.fromhex("7ca20a") # 28.0 C / 63.6 %RH + + +def _sensor(msd_data_start_byte: int = 7) -> SensorData: + return SensorData( + instance_number=0, + sensor_type=SensorType.SHT40, + bus_id=0, + i2c_addr_7bit=0x44, + msd_data_start_byte=msd_data_start_byte, + ) + + +def _update(block: bytes = READING, start_byte: int = 7) -> OpenDisplayUpdate: + """A coordinator update whose advertisement carries an SHT40 reading.""" + dynamic = bytearray(11) + dynamic[start_byte : start_byte + 3] = block + advertisement = parse_advertisement(bytes(dynamic) + bytes([124, 139, 0])) + return OpenDisplayUpdate(address=ADDRESS, advertisement=advertisement) + + +def _values(sensor: SensorData, update: OpenDisplayUpdate) -> dict[str, float | None]: + return {d.key: d.value_fn(update) for d in _sht40_descriptions(sensor)} + + +def test_reads_temperature_and_humidity(): + values = _values(_sensor(), _update()) + + assert values["sht40_0_temperature"] == 28.0 + assert values["sht40_0_humidity"] == 63.6 + + +def test_reads_from_the_configured_offset(): + """E1001/E1002/E1004 place the block at 1, not the firmware default of 7.""" + values = _values(_sensor(msd_data_start_byte=1), _update(start_byte=1)) + + assert values["sht40_0_temperature"] == 28.0 + + +def test_offset_zero_resolves_to_the_default_slot(): + """0 means "use the default", not byte 0 -- so the reading is still found.""" + values = _values(_sensor(msd_data_start_byte=0), _update(start_byte=7)) + + assert values["sht40_0_temperature"] == 28.0 + + +def test_failed_read_reports_unknown(): + """FF FF FF is the firmware's read-failure sentinel, not a measurement.""" + values = _values(_sensor(), _update(block=b"\xff\xff\xff")) + + assert values["sht40_0_temperature"] is None + assert values["sht40_0_humidity"] is None + + +def test_unwritten_slot_reports_unknown(): + """An all-zero slot decodes to -40 C / 0 %RH but means "never written".""" + values = _values(_sensor(), _update(block=b"\x00\x00\x00")) + + assert values["sht40_0_temperature"] is None + assert values["sht40_0_humidity"] is None + + +def test_entities_are_primary_not_diagnostic(): + """Ambient readings are what the device is for; the chip temperature is not.""" + for description in _sht40_descriptions(_sensor()): + assert description.entity_category is None + assert description.entity_registry_enabled_default is True + + +def test_chip_temperature_stays_diagnostic_and_disabled(): + assert _TEMPERATURE_DESCRIPTION.entity_category is not None + assert _TEMPERATURE_DESCRIPTION.entity_registry_enabled_default is False + + +def test_chip_temperature_keeps_its_unique_id_key(): + """Renaming is display-only: changing the key would orphan existing entities.""" + assert _TEMPERATURE_DESCRIPTION.key == "temperature" + assert _TEMPERATURE_DESCRIPTION.translation_key == "chip_temperature" + + +def test_keys_are_distinct_per_instance(): + """A second SHT40 must not collide with the first one's unique_id.""" + first = {d.key for d in _sht40_descriptions(_sensor())} + second_sensor = _sensor() + second_sensor.instance_number = 1 + + assert first.isdisjoint({d.key for d in _sht40_descriptions(second_sensor)}) + + +def test_value_is_none_before_any_advertisement(): + """native_value guards on coordinator.data being None.""" + from custom_components.opendisplay.sensor import OpenDisplaySensorEntity + + coordinator = MagicMock() + coordinator.data = None + entity = OpenDisplaySensorEntity(coordinator, _sht40_descriptions(_sensor())[0]) + + assert entity.native_value is None