From 0aa630b06e464a0971930f107d160aa35bafc11f Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 29 Jul 2026 01:13:30 +0800 Subject: [PATCH] feat(sensor): expose panel resolution, physical size and colour scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel reports its pixel dimensions, physical size, colour scheme and rotation in the display config packet, but none of it reached Home Assistant: __init__.py reads the pixel dimensions to build the device model and then discards them whenever the diagonal is known, and the diagonal it shows is itself derived from the millimetres. Consumers were left string-parsing a human-facing display name, and could not get the resolution at all. That blocks a real use case: an automation that renders an image at runtime and pushes it with opendisplay.upload_image needs the panel's resolution and colour scheme to size and quantise the render. A template can only read entity state, so the values have to live on an entity. Adds sensor._resolution, state WIDTHxHEIGHT, with the pixel and physical dimensions, colour scheme and rotation as attributes. Diagnostic and disabled by default, like the other device-fact sensors. - One sensor per device, reporting displays[0]. The config packet is repeatable (max 4), but upload_image and drawcustom take no display argument and both use displays[0] — unlike activate_led/activate_buzzer/play_melody, which do take an instance. A sensor for display 1..3 would describe a panel nothing can draw on, and a consumer sizing a render from it would silently produce an image for the wrong panel. If display addressing is added to those services, this grows to match, keyed the same way. - The state is the panel's NATIVE resolution and is never transposed for rotation. Resolution and rotation are independent facts; folding one into the other would make a panel's resolution change when someone rotates it, and a consumer that wants the rotated extent can combine the two. - Rotation is reported in degrees rather than the firmware's index, and is None when the raw value maps to neither, so an index can never be mistaken for an angle. - The config is re-read on every access. delivery.py replaces runtime_data.device_config wholesale on a wake-time resync without reloading the entry, so a snapshot taken at construction would serve stale geometry. - Stays available while the panel sleeps, since the value comes from config rather than from advertisements. value_fn becomes optional on the description: last_seen already passed a dead no-op to satisfy it, and this sensor would have been a second one. --- custom_components/opendisplay/icons.json | 5 + custom_components/opendisplay/sensor.py | 88 ++++++++++++-- custom_components/opendisplay/strings.json | 3 + .../opendisplay/translations/en.json | 3 + tests/snapshots/test_sensor.ambr | 112 ++++++++++++++++++ tests/test_sensor.py | 66 +++++++++++ 6 files changed, 266 insertions(+), 11 deletions(-) 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