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