Skip to content
Open
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
5 changes: 5 additions & 0 deletions custom_components/opendisplay/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"firmware": {
"default": "mdi:chip"
}
},
"sensor": {
"resolution": {
"default": "mdi:aspect-ratio"
}
}
},
"services": {
Expand Down
88 changes: 77 additions & 11 deletions custom_components/opendisplay/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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,
}
3 changes: 3 additions & 0 deletions custom_components/opendisplay/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@
},
"last_seen": {
"name": "Last seen"
},
"resolution": {
"name": "Resolution"
}
},
"update": {
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 @@ -128,6 +128,9 @@
},
"last_seen": {
"name": "Last seen"
},
"resolution": {
"name": "Resolution"
}
},
"update": {
Expand Down
112 changes: 112 additions & 0 deletions tests/snapshots/test_sensor.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.opendisplay_1234_resolution',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'OpenDisplay 1234 Resolution',
'pixel_height': 128,
'pixel_width': 296,
'rotation': 0,
}),
'context': <ANY>,
'entity_id': 'sensor.opendisplay_1234_resolution',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '296x128',
})
# ---
# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_signal_strength_rssi-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
Expand Down Expand Up @@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.opendisplay_1234_resolution',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'OpenDisplay 1234 Resolution',
'pixel_height': 128,
'pixel_width': 296,
'rotation': 0,
}),
'context': <ANY>,
'entity_id': 'sensor.opendisplay_1234_resolution',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '296x128',
})
# ---
# name: test_sensor_entities_usb_device[sensor.opendisplay_1234_signal_strength_rssi-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
Expand Down
66 changes: 66 additions & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Loading