Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/opendisplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
27 changes: 24 additions & 3 deletions custom_components/opendisplay/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
TouchChangeEvent,
TouchTracker,
)
from opendisplay.models.config import BinaryInputs

from homeassistant.components.bluetooth import (
BluetoothChange,
Expand Down Expand Up @@ -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,
Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion custom_components/opendisplay/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
61 changes: 59 additions & 2 deletions custom_components/opendisplay/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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 += [
Expand Down
3 changes: 3 additions & 0 deletions custom_components/opendisplay/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
}
},
"sensor": {
"chip_temperature": {
"name": "Chip temperature"
},
"battery_voltage": {
"name": "Battery voltage"
},
Expand Down
3 changes: 3 additions & 0 deletions custom_components/opendisplay/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
}
},
"sensor": {
"chip_temperature": {
"name": "Chip temperature"
},
"battery_voltage": {
"name": "Battery voltage"
},
Expand Down
118 changes: 118 additions & 0 deletions tests/test_sht40_sensors.py
Original file line number Diff line number Diff line change
@@ -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
Loading