diff --git a/custom_components/opendisplay/icons.json b/custom_components/opendisplay/icons.json index 87505c5..fcdd831 100644 --- a/custom_components/opendisplay/icons.json +++ b/custom_components/opendisplay/icons.json @@ -18,6 +18,11 @@ "firmware": { "default": "mdi:chip" } + }, + "sensor": { + "resolution": { + "default": "mdi:aspect-ratio" + } } }, "services": { diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index f257925..090281b 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -24,11 +24,11 @@ from opendisplay import voltage_to_percent from opendisplay.models.advertisement import Sht40Reading -from opendisplay.models.config import SensorData -from opendisplay.models.enums import CapacityEstimator, PowerMode, SensorType +from opendisplay.models.config import DisplayConfig, SensorData +from opendisplay.models.enums import CapacityEstimator, PowerMode, Rotation, SensorType from . import OpenDisplayConfigEntry -from .coordinator import OpenDisplayUpdate +from .coordinator import OpenDisplayCoordinator, OpenDisplayUpdate from .entity import OpenDisplayEntity PARALLEL_UPDATES = 0 @@ -38,7 +38,9 @@ class OpenDisplaySensorEntityDescription(SensorEntityDescription): """Describes an OpenDisplay sensor entity.""" - value_fn: Callable[[OpenDisplayUpdate], float | int | str | datetime | None] + value_fn: ( + Callable[[OpenDisplayUpdate], float | int | str | datetime | None] | None + ) = None # The MCU's own temperature, not an attached sensor. translation_key only sets @@ -132,9 +134,13 @@ def _humidity(upd: OpenDisplayUpdate) -> float | None: device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - # native_value is overridden by OpenDisplayLastSeenSensor, so this value_fn - # is dead code; value_fn is a required field, hence the no-op. - value_fn=lambda _upd: None, +) + +_RESOLUTION_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="resolution", + translation_key="resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ) @@ -173,14 +179,21 @@ async def async_setup_entry( ), ] - async_add_entities( + entities: list[OpenDisplaySensorEntity] = [ ( OpenDisplayLastSeenSensor(coordinator, description) if description.key == "last_seen" else OpenDisplaySensorEntity(coordinator, description) ) for description in descriptions - ) + ] + + if device_config.displays: + entities.append( + OpenDisplayResolutionSensor(coordinator, _RESOLUTION_DESCRIPTION, entry) + ) + + async_add_entities(entities) class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): @@ -191,9 +204,10 @@ class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): @property def native_value(self) -> float | int | str | datetime | None: """Return the sensor value.""" - if self.coordinator.data is None: + value_fn = self.entity_description.value_fn + if value_fn is None or self.coordinator.data is None: return None - return self.entity_description.value_fn(self.coordinator.data) + return value_fn(self.coordinator.data) class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity): @@ -214,3 +228,55 @@ def native_value(self) -> datetime | None: # wall time with the same offset the advertisement monitor uses. wall = info.time + (time.time() - time.monotonic()) return datetime.fromtimestamp(wall, tz=UTC) + + +class OpenDisplayResolutionSensor(OpenDisplaySensorEntity): + """A panel's native resolution, with its physical size and colour scheme.""" + + def __init__( + self, + coordinator: OpenDisplayCoordinator, + description: OpenDisplaySensorEntityDescription, + entry: OpenDisplayConfigEntry, + ) -> None: + """Initialize against the config entry whose display is reported.""" + super().__init__(coordinator, description) + self._entry = entry + + @property + def _display(self) -> DisplayConfig | None: + """Return the display config, re-read since a resync replaces it.""" + displays = self._entry.runtime_data.device_config.displays + return displays[0] if displays else None + + @property + def available(self) -> bool: + """Return True while the display is in the config, awake or not.""" + return self._display is not None + + @property + def native_value(self) -> str | None: + """Return the native resolution as ``WIDTHxHEIGHT``.""" + display = self._display + if display is None: + return None + return f"{display.pixel_width}x{display.pixel_height}" + + @property + def extra_state_attributes(self) -> dict[str, int | str | None] | None: + """Return the panel's pixel and physical dimensions, scheme and rotation.""" + display = self._display + if display is None: + return None + color_scheme = display.color_scheme_enum + rotation = display.rotation_enum + return { + "pixel_width": display.pixel_width, + "pixel_height": display.pixel_height, + "active_width_mm": display.active_width_mm, + "active_height_mm": display.active_height_mm, + "color_scheme": ( + color_scheme if isinstance(color_scheme, int) else color_scheme.name + ), + "rotation": int(rotation) if isinstance(rotation, Rotation) else None, + } diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 5706de5..0f45ded 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -128,6 +128,9 @@ }, "last_seen": { "name": "Last seen" + }, + "resolution": { + "name": "Resolution" } }, "update": { diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index f0d1a54..f592de9 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -128,6 +128,9 @@ }, "last_seen": { "name": "Last seen" + }, + "resolution": { + "name": "Resolution" } }, "update": { diff --git a/tests/snapshots/test_sensor.ambr b/tests/snapshots/test_sensor.ambr index 4ff3180..fa4f5fc 100644 --- a/tests/snapshots/test_sensor.ambr +++ b/tests/snapshots/test_sensor.ambr @@ -221,6 +221,62 @@ 'state': '2026-01-01T00:00:00+00:00', }) # --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_resolution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opendisplay_1234_resolution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Resolution', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resolution', + 'platform': 'opendisplay', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resolution', + 'unique_id': 'AA:BB:CC:DD:EE:FF-resolution', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_resolution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_height_mm': 29, + 'active_width_mm': 67, + 'color_scheme': 'BWR', + : 'OpenDisplay 1234 Resolution', + 'pixel_height': 128, + 'pixel_width': 296, + 'rotation': 0, + }), + 'context': , + 'entity_id': 'sensor.opendisplay_1234_resolution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '296x128', + }) +# --- # name: test_sensor_entities_battery_device[sensor.opendisplay_1234_signal_strength_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -385,6 +441,62 @@ 'state': '2026-01-01T00:00:00+00:00', }) # --- +# name: test_sensor_entities_usb_device[sensor.opendisplay_1234_resolution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opendisplay_1234_resolution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Resolution', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resolution', + 'platform': 'opendisplay', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resolution', + 'unique_id': 'AA:BB:CC:DD:EE:FF-resolution', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_usb_device[sensor.opendisplay_1234_resolution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_height_mm': 29, + 'active_width_mm': 67, + 'color_scheme': 'BWR', + : 'OpenDisplay 1234 Resolution', + 'pixel_height': 128, + 'pixel_width': 296, + 'rotation': 0, + }), + 'context': , + 'entity_id': 'sensor.opendisplay_1234_resolution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '296x128', + }) +# --- # name: test_sensor_entities_usb_device[sensor.opendisplay_1234_signal_strength_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 8ebf411..736aa50 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -5,6 +5,7 @@ from dataclasses import replace from datetime import timedelta import time +from types import SimpleNamespace from unittest.mock import MagicMock from habluetooth import CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS @@ -14,6 +15,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from opendisplay import voltage_to_percent +from opendisplay.models.config import DisplayConfig from opendisplay.models.enums import CapacityEstimator, PowerMode import pytest from pytest_homeassistant_custom_component.common import ( @@ -24,7 +26,9 @@ from syrupy.assertion import SnapshotAssertion from custom_components.opendisplay.sensor import ( + _RESOLUTION_DESCRIPTION, _TEMPERATURE_DESCRIPTION, + OpenDisplayResolutionSensor, _sht40_descriptions, ) from tests.bluetooth import ( @@ -389,3 +393,65 @@ async def test_last_seen_unknown_before_any_advertisement( assert ( hass.states.get("sensor.opendisplay_1234_last_seen").state == STATE_UNAVAILABLE ) + + +# --- resolution ------------------------------------------------------------ + + +def _resolution_sensor(*displays: DisplayConfig) -> OpenDisplayResolutionSensor: + """Return a resolution sensor over a runtime_data whose config can be swapped.""" + entry = SimpleNamespace( + runtime_data=SimpleNamespace( + device_config=SimpleNamespace(displays=list(displays)) + ) + ) + coordinator = MagicMock() + coordinator.address = TEST_ADDRESS + return OpenDisplayResolutionSensor(coordinator, _RESOLUTION_DESCRIPTION, entry) + + +def test_resolution_reports_the_panel_as_configured() -> None: + """The state is the native geometry; the rest of the packet becomes attributes.""" + sensor = _resolution_sensor(DEVICE_CONFIG.displays[0]) + + assert sensor.available + assert sensor.native_value == "296x128" + assert sensor.extra_state_attributes == { + "pixel_width": 296, + "pixel_height": 128, + "active_width_mm": 67, + "active_height_mm": 29, + "color_scheme": "BWR", + "rotation": 0, + } + + +def test_unrecognised_values_do_not_masquerade_as_valid_ones() -> None: + """Rotation is reported in degrees, so an unmapped index must not pass for one.""" + display = replace(DEVICE_CONFIG.displays[0], rotation=99, color_scheme=99) + + attrs = _resolution_sensor(display).extra_state_attributes + + assert attrs["rotation"] is None + assert attrs["color_scheme"] == 99 + + +def test_config_is_re_read_so_a_wake_time_resync_is_picked_up() -> None: + """delivery.py replaces device_config wholesale; a cached display would go stale.""" + sensor = _resolution_sensor(DEVICE_CONFIG.displays[0]) + assert sensor.native_value == "296x128" + + sensor._entry.runtime_data.device_config = SimpleNamespace( + displays=[replace(DEVICE_CONFIG.displays[0], pixel_width=960, pixel_height=640)] + ) + + assert sensor.native_value == "960x640" + + +def test_a_display_less_device_reports_nothing() -> None: + """No display in the config means no geometry to report, not a zero-sized panel.""" + sensor = _resolution_sensor() + + assert not sensor.available + assert sensor.native_value is None + assert sensor.extra_state_attributes is None