From f3ef75ae908a6451417916927d300a9263eb8603 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 | 87 ++++++++++++++++--- custom_components/opendisplay/strings.json | 3 + .../opendisplay/translations/en.json | 3 + tests/test_resolution.py | 72 +++++++++++++++ 5 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 tests/test_resolution.py diff --git a/custom_components/opendisplay/icons.json b/custom_components/opendisplay/icons.json index a5f28a3..4ac65df 100644 --- a/custom_components/opendisplay/icons.json +++ b/custom_components/opendisplay/icons.json @@ -12,6 +12,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 8107ed0..bf1cb5d 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -6,7 +6,8 @@ import time from opendisplay import voltage_to_percent -from opendisplay.models.enums import CapacityEstimator, PowerMode +from opendisplay.models.config import DisplayConfig +from opendisplay.models.enums import CapacityEstimator, PowerMode, Rotation from homeassistant.components.bluetooth import async_last_service_info from homeassistant.components.sensor import ( @@ -26,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenDisplayConfigEntry -from .coordinator import OpenDisplayUpdate +from .coordinator import OpenDisplayCoordinator, OpenDisplayUpdate from .entity import OpenDisplayEntity PARALLEL_UPDATES = 0 @@ -36,7 +37,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 _TEMPERATURE_DESCRIPTION = OpenDisplaySensorEntityDescription( @@ -79,9 +82,13 @@ class OpenDisplaySensorEntityDescription(SensorEntityDescription): 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, ) @@ -115,14 +122,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 entry.runtime_data.device_config.displays: + entities.append( + OpenDisplayResolutionSensor(coordinator, _RESOLUTION_DESCRIPTION, entry) + ) + + async_add_entities(entities) class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): @@ -133,9 +147,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): @@ -155,3 +170,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=timezone.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 1d291e1..7f98a9a 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -117,6 +117,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 eae8fe1..7eb483d 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -117,6 +117,9 @@ }, "last_seen": { "name": "Last seen" + }, + "resolution": { + "name": "Resolution" } }, "update": { diff --git a/tests/test_resolution.py b/tests/test_resolution.py new file mode 100644 index 0000000..a3c5213 --- /dev/null +++ b/tests/test_resolution.py @@ -0,0 +1,72 @@ +"""Unit tests for the resolution sensor. + +Like test_last_seen, these avoid the full Home Assistant test harness. Real +``DisplayConfig`` instances are used rather than stubs, so rotation and colour +scheme resolve as they do in production. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from opendisplay.models.config import DisplayConfig + +from custom_components.opendisplay.sensor import ( + _RESOLUTION_DESCRIPTION, + OpenDisplayResolutionSensor, +) + + +def _display(rotation=2, color_scheme=4, **overrides): + """Return a real DisplayConfig, matching a 7.3" six-colour panel.""" + fields = { + "instance_number": 0, + "display_technology": 1, + "panel_ic_type": 35, + "pixel_width": 800, + "pixel_height": 480, + "active_width_mm": 160, + "active_height_mm": 96, + "tag_type": 0, + "rotation": rotation, + "reset_pin": 12, + "busy_pin": 13, + "dc_pin": 8, + "cs_pin": 9, + "data_pin": 11, + "partial_update_support": 0, + "color_scheme": color_scheme, + "transmission_modes": 9, + "clk_pin": 10, + "reserved_pins": b"\x00" * 7, + "full_update_mC": 400, + "reserved": b"\x00" * 13, + } + fields.update(overrides) + return DisplayConfig(**fields) + + +def _make_sensor(*displays): + """Return a resolution sensor reading from a mutable runtime_data.""" + entry = SimpleNamespace( + runtime_data=SimpleNamespace( + device_config=SimpleNamespace(displays=list(displays)) + ) + ) + coordinator = MagicMock() + coordinator.address = "AA:BB:CC:DD:EE:FF" + return OpenDisplayResolutionSensor(coordinator, _RESOLUTION_DESCRIPTION, entry) + + +def test_unrecognised_values_do_not_masquerade_as_valid_ones(): + attrs = _make_sensor(_display(rotation=99, color_scheme=99)).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(): + sensor = _make_sensor(_display()) + assert sensor.native_value == "800x480" + sensor._entry.runtime_data.device_config = SimpleNamespace( + displays=[_display(pixel_width=960, pixel_height=640)] + ) + assert sensor.native_value == "960x640"